From 308f73011a087acd4dc3e11d4a4e20f577410eb4 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 28 Aug 2025 15:13:56 -0700 Subject: [PATCH 01/34] Use Helm binary instead of the Go SDK to manage charts --- Makefile | 19 + api/controllers/app/install/controller.go | 18 +- .../kubernetes/install/controller.go | 47 +- api/controllers/linux/install/controller.go | 2 +- api/integration/app/install/config_test.go | 2 + .../kubernetes/install/apppreflight_test.go | 10 +- .../handlers/kubernetes/kubernetes.go | 9 +- .../managers/app/install/install_test.go | 15 +- api/internal/managers/app/install/manager.go | 33 +- api/internal/managers/app/install/util.go | 7 +- .../managers/kubernetes/infra/manager.go | 47 +- .../managers/kubernetes/infra/manager_test.go | 59 +- api/internal/managers/linux/infra/install.go | 2 +- api/internal/managers/linux/infra/util.go | 22 +- api/types/api.go | 4 +- cmd/buildtools/metadata.go | 8 +- cmd/buildtools/openebs.go | 4 +- cmd/buildtools/registry.go | 4 +- cmd/buildtools/seaweedfs.go | 4 +- cmd/buildtools/utils.go | 22 +- cmd/buildtools/velero.go | 4 +- cmd/installer/cli/enable_ha.go | 6 +- cmd/installer/cli/install.go | 19 +- cmd/installer/cli/join.go | 6 +- cmd/installer/cli/restore.go | 18 +- cmd/installer/goods/materializer.go | 24 + .../kubernetesinstallation/installation.go | 20 +- pkg-new/kubernetesinstallation/interface.go | 4 + pkg-new/kubernetesinstallation/mock.go | 15 + .../integration/hostcabundle_test.go | 4 +- .../integration/kubernetes_test.go | 4 +- .../adminconsole/integration/linux_test.go | 4 +- .../integration/hostcabundle_test.go | 4 +- .../velero/integration/hostcabundle_test.go | 4 +- .../velero/integration/k0ssubdir_test.go | 4 +- pkg/extensions/install.go | 2 +- pkg/extensions/upgrade.go | 2 +- pkg/extensions/util.go | 4 +- pkg/helm/binary_executor.go | 54 ++ pkg/helm/binary_executor_mock.go | 20 + pkg/helm/binary_executor_test.go | 199 +++++ pkg/helm/client.go | 781 +++++++++--------- pkg/helm/client_test.go | 438 ++++++++++ pkg/helm/images.go | 6 +- pkg/helm/interface.go | 19 +- pkg/helm/mock_client.go | 41 +- pkg/helm/output_parser.go | 26 + pkg/helm/output_parser_test.go | 150 ++++ pkg/runtimeconfig/interface.go | 3 + pkg/runtimeconfig/mock.go | 10 + pkg/runtimeconfig/runtimeconfig.go | 9 + proposals/helm_binary_migration.md | 365 ++++++++ proposals/helm_binary_migration_research.md | 427 ++++++++++ proposals/v3_app_deployment_transition.md | 60 +- tests/integration/util/helm.go | 6 +- versions.mk | 3 + 56 files changed, 2446 insertions(+), 657 deletions(-) create mode 100644 pkg/helm/binary_executor.go create mode 100644 pkg/helm/binary_executor_mock.go create mode 100644 pkg/helm/binary_executor_test.go create mode 100644 pkg/helm/output_parser.go create mode 100644 pkg/helm/output_parser_test.go create mode 100644 proposals/helm_binary_migration.md create mode 100644 proposals/helm_binary_migration_research.md diff --git a/Makefile b/Makefile index 017ce95531..94a6bc3443 100644 --- a/Makefile +++ b/Makefile @@ -104,6 +104,23 @@ output/bins/kubectl-support_bundle-%: rm -rf output/tmp touch $@ +.PHONY: cmd/installer/goods/bins/helm +cmd/installer/goods/bins/helm: + $(MAKE) output/bins/helm-$(HELM_VERSION)-$(ARCH) + cp output/bins/helm-$(HELM_VERSION)-$(ARCH) $@ + touch $@ + +output/bins/helm-%: + mkdir -p output/bins + mkdir -p output/tmp + curl --retry 5 --retry-all-errors -fL -o output/tmp/helm.tar.gz \ + https://get.helm.sh/helm-$(call split-hyphen,$*,1)-$(OS)-$(call split-hyphen,$*,2).tar.gz + tar -xzf output/tmp/helm.tar.gz -C output/tmp + mv output/tmp/$(OS)-$(call split-hyphen,$*,2)/helm $@ + rm -rf output/tmp + chmod +x $@ + touch $@ + .PHONY: cmd/installer/goods/bins/kubectl-preflight cmd/installer/goods/bins/kubectl-preflight: $(MAKE) output/bins/kubectl-preflight-$(TROUBLESHOOT_VERSION)-$(ARCH) @@ -229,6 +246,7 @@ static: cmd/installer/goods/bins/k0s \ cmd/installer/goods/bins/kubectl-support_bundle \ cmd/installer/goods/bins/local-artifact-mirror \ cmd/installer/goods/bins/fio \ + cmd/installer/goods/bins/helm \ cmd/installer/goods/internal/bins/kubectl-kots .PHONY: static-dryrun @@ -238,6 +256,7 @@ static-dryrun: cmd/installer/goods/bins/kubectl-support_bundle \ cmd/installer/goods/bins/local-artifact-mirror \ cmd/installer/goods/bins/fio \ + cmd/installer/goods/bins/helm \ cmd/installer/goods/internal/bins/kubectl-kots .PHONY: embedded-cluster-linux-amd64 diff --git a/api/controllers/app/install/controller.go b/api/controllers/app/install/controller.go index 751b1e5a10..3550a83ab7 100644 --- a/api/controllers/app/install/controller.go +++ b/api/controllers/app/install/controller.go @@ -16,7 +16,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" - "k8s.io/cli-runtime/pkg/genericclioptions" + helmcli "helm.sh/helm/v3/pkg/cli" kyaml "sigs.k8s.io/yaml" ) @@ -49,8 +49,7 @@ type InstallController struct { airgapBundle string privateCACertConfigMapName string k8sVersion string - restClientGetter genericclioptions.RESTClientGetter - kubeConfigPath string + kubernetesEnvSettings *helmcli.EnvSettings } type InstallControllerOption func(*InstallController) @@ -139,15 +138,9 @@ func WithK8sVersion(k8sVersion string) InstallControllerOption { } } -func WithRESTClientGetter(restClientGetter genericclioptions.RESTClientGetter) InstallControllerOption { +func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) InstallControllerOption { return func(c *InstallController) { - c.restClientGetter = restClientGetter - } -} - -func WithKubeConfigPath(kubeConfigPath string) InstallControllerOption { - return func(c *InstallController) { - c.kubeConfigPath = kubeConfigPath + c.kubernetesEnvSettings = envSettings } } @@ -229,8 +222,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appinstallmanager.WithAirgapBundle(controller.airgapBundle), appinstallmanager.WithAppInstallStore(controller.store.AppInstallStore()), appinstallmanager.WithK8sVersion(controller.k8sVersion), - appinstallmanager.WithRESTClientGetter(controller.restClientGetter), - appinstallmanager.WithKubeConfigPath(controller.kubeConfigPath), + appinstallmanager.WithKubernetesEnvSettings(controller.kubernetesEnvSettings), ) if err != nil { return nil, fmt.Errorf("create app install manager: %w", err) diff --git a/api/controllers/kubernetes/install/controller.go b/api/controllers/kubernetes/install/controller.go index 2a06c18a96..90dd8f9f5a 100644 --- a/api/controllers/kubernetes/install/controller.go +++ b/api/controllers/kubernetes/install/controller.go @@ -18,7 +18,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/sirupsen/logrus" helmcli "helm.sh/helm/v3/pkg/cli" - "k8s.io/cli-runtime/pkg/genericclioptions" ) type Controller interface { @@ -34,22 +33,22 @@ type Controller interface { var _ Controller = (*InstallController)(nil) type InstallController struct { - installationManager installation.InstallationManager - infraManager infra.InfraManager - metricsReporter metrics.ReporterInterface - k8sVersion string - restClientGetter genericclioptions.RESTClientGetter - releaseData *release.ReleaseData - password string - tlsConfig types.TLSConfig - license []byte - airgapBundle string - configValues types.AppConfigValues - endUserConfig *ecv1beta1.Config - store store.Store - ki kubernetesinstallation.Installation - stateMachine statemachine.Interface - logger logrus.FieldLogger + installationManager installation.InstallationManager + infraManager infra.InfraManager + metricsReporter metrics.ReporterInterface + k8sVersion string + kubernetesEnvSettings *helmcli.EnvSettings + releaseData *release.ReleaseData + password string + tlsConfig types.TLSConfig + license []byte + airgapBundle string + configValues types.AppConfigValues + endUserConfig *ecv1beta1.Config + store store.Store + ki kubernetesinstallation.Installation + stateMachine statemachine.Interface + logger logrus.FieldLogger // App controller composition *appcontroller.InstallController } @@ -80,9 +79,9 @@ func WithK8sVersion(k8sVersion string) InstallControllerOption { } } -func WithRESTClientGetter(restClientGetter genericclioptions.RESTClientGetter) InstallControllerOption { +func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) InstallControllerOption { return func(c *InstallController) { - c.restClientGetter = restClientGetter + c.kubernetesEnvSettings = envSettings } } @@ -176,9 +175,9 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, controller.stateMachine = NewStateMachine(WithStateMachineLogger(controller.logger)) } - // If none is provided, use the default env settings from helm to create a RESTClientGetter - if controller.restClientGetter == nil { - controller.restClientGetter = helmcli.New().RESTClientGetter() + // If none is provided, use the default env settings from helm + if controller.kubernetesEnvSettings == nil { + controller.kubernetesEnvSettings = helmcli.New() } if controller.installationManager == nil { @@ -200,7 +199,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appcontroller.WithAirgapBundle(controller.airgapBundle), appcontroller.WithPrivateCACertConfigMapName(""), // Private CA ConfigMap functionality not yet implemented for Kubernetes installations appcontroller.WithK8sVersion(controller.k8sVersion), // Used to determine the kubernetes version for the helm client - appcontroller.WithRESTClientGetter(controller.restClientGetter), + appcontroller.WithKubernetesEnvSettings(controller.kubernetesEnvSettings), ) if err != nil { return nil, fmt.Errorf("create app install controller: %w", err) @@ -212,7 +211,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, infraManager, err := infra.NewInfraManager( infra.WithLogger(controller.logger), infra.WithInfraStore(controller.store.KubernetesInfraStore()), - infra.WithRESTClientGetter(controller.restClientGetter), + infra.WithKubernetesEnvSettings(controller.kubernetesEnvSettings), infra.WithPassword(controller.password), infra.WithTLSConfig(controller.tlsConfig), infra.WithLicense(controller.license), diff --git a/api/controllers/linux/install/controller.go b/api/controllers/linux/install/controller.go index b6f7a3be5b..884dd70405 100644 --- a/api/controllers/linux/install/controller.go +++ b/api/controllers/linux/install/controller.go @@ -268,7 +268,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appcontroller.WithAirgapBundle(controller.airgapBundle), appcontroller.WithPrivateCACertConfigMapName(adminconsole.PrivateCASConfigMapName), // Linux installations use the ConfigMap appcontroller.WithK8sVersion(versions.K0sVersion), // Used to determine the kubernetes version for the helm client - appcontroller.WithKubeConfigPath(controller.rc.PathToKubeConfig()), + appcontroller.WithKubernetesEnvSettings(controller.rc.GetKubernetesEnvSettings()), ) if err != nil { return nil, fmt.Errorf("create app install controller: %w", err) diff --git a/api/integration/app/install/config_test.go b/api/integration/app/install/config_test.go index f44266f19e..3acb299d27 100644 --- a/api/integration/app/install/config_test.go +++ b/api/integration/app/install/config_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + helmcli "helm.sh/helm/v3/pkg/cli" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -1069,6 +1070,7 @@ func TestAppInstallSuite(t *testing.T) { kubernetesinstall.WithLicense([]byte("spec:\n licenseID: test-license\n")), kubernetesinstall.WithConfigValues(configValues), kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithKubernetesEnvSettings(helmcli.New()), ) require.NoError(t, err) // Create the API with the install controller diff --git a/api/integration/kubernetes/install/apppreflight_test.go b/api/integration/kubernetes/install/apppreflight_test.go index 13d6231a00..49bec6cd40 100644 --- a/api/integration/kubernetes/install/apppreflight_test.go +++ b/api/integration/kubernetes/install/apppreflight_test.go @@ -26,7 +26,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "k8s.io/cli-runtime/pkg/genericclioptions" ) // Test the getAppPreflightsStatus endpoint returns app preflights status correctly @@ -246,8 +245,7 @@ func TestPostRunAppPreflights(t *testing.T) { InstallTarget: types.InstallTargetKubernetes, Password: "password", KubernetesConfig: types.KubernetesConfig{ - RESTClientGetter: &genericclioptions.ConfigFlags{}, - Installation: mockInstallation, + Installation: mockInstallation, }, ReleaseData: integration.DefaultReleaseData(), }, @@ -299,8 +297,7 @@ func TestPostRunAppPreflights(t *testing.T) { InstallTarget: types.InstallTargetKubernetes, Password: "password", KubernetesConfig: types.KubernetesConfig{ - RESTClientGetter: &genericclioptions.ConfigFlags{}, - Installation: mockInstallation, + Installation: mockInstallation, }, ReleaseData: integration.DefaultReleaseData(), }, @@ -346,8 +343,7 @@ func TestPostRunAppPreflights(t *testing.T) { InstallTarget: types.InstallTargetKubernetes, Password: "password", KubernetesConfig: types.KubernetesConfig{ - RESTClientGetter: &genericclioptions.ConfigFlags{}, - Installation: mockInstallation, + Installation: mockInstallation, }, ReleaseData: integration.DefaultReleaseData(), }, diff --git a/api/internal/handlers/kubernetes/kubernetes.go b/api/internal/handlers/kubernetes/kubernetes.go index 141f6be8fd..14e17f8cf3 100644 --- a/api/internal/handlers/kubernetes/kubernetes.go +++ b/api/internal/handlers/kubernetes/kubernetes.go @@ -8,6 +8,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/sirupsen/logrus" + "k8s.io/cli-runtime/pkg/genericclioptions" ) type Handler struct { @@ -52,7 +53,11 @@ func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { // TODO (@team): discuss which of these should / should not be pointers if h.installController == nil { - k8sVersion, err := getK8sVersion(h.cfg.RESTClientGetter) + var restClientGetter genericclioptions.RESTClientGetter + if ks := h.cfg.Installation.GetKubernetesEnvSettings(); ks != nil { + restClientGetter = ks.RESTClientGetter() + } + k8sVersion, err := getK8sVersion(restClientGetter) if err != nil { return nil, fmt.Errorf("get k8s version: %w", err) } @@ -61,7 +66,7 @@ func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { install.WithLogger(h.logger), install.WithMetricsReporter(h.metricsReporter), install.WithK8sVersion(k8sVersion), - install.WithRESTClientGetter(h.cfg.RESTClientGetter), + install.WithKubernetesEnvSettings(h.cfg.Installation.GetKubernetesEnvSettings()), install.WithReleaseData(h.cfg.ReleaseData), install.WithConfigValues(h.cfg.ConfigValues), install.WithEndUserConfig(h.cfg.EndUserConfig), diff --git a/api/internal/managers/app/install/install_test.go b/api/internal/managers/app/install/install_test.go index 9ea185f898..e363531782 100644 --- a/api/internal/managers/app/install/install_test.go +++ b/api/internal/managers/app/install/install_test.go @@ -22,7 +22,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - helmrelease "helm.sh/helm/v3/pkg/release" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kyaml "sigs.k8s.io/yaml" ) @@ -97,7 +96,7 @@ func TestAppInstallManager_Install(t *testing.T) { return vals["repository"] == "nginx" && vals["tag"] == "latest" && opts.Values["replicas"] == 3 } return false - })).Return(&helmrelease.Release{Name: "web-server"}, nil) + })).Return("Release \"web-server\" has been installed.", nil) // Chart 2 installation (database chart) databaseCall := mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { @@ -112,7 +111,7 @@ func TestAppInstallManager_Install(t *testing.T) { return vals["host"] == "postgres.example.com" && vals["password"] == "secret" } return false - })).Return(&helmrelease.Release{Name: "database"}, nil) + })).Return("Release \"database\" has been installed.", nil) // Verify installation order mock.InOrder( @@ -205,7 +204,7 @@ func TestAppInstallManager_Install(t *testing.T) { mockHelmClient := &helm.MockClient{} mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { return opts.ChartPath != "" && opts.ReleaseName == "prometheus" && opts.Namespace == "monitoring" - })).Return(&helmrelease.Release{Name: "prometheus"}, nil) + })).Return("Release \"prometheus\" has been installed.", nil) // Create mock installer that succeeds mockInstaller := &MockKotsCLIInstaller{} @@ -255,7 +254,7 @@ func TestAppInstallManager_Install(t *testing.T) { mockHelmClient := &helm.MockClient{} mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { return opts.ChartPath != "" && opts.ReleaseName == "fluentd" && opts.Namespace == "logging" - })).Return((*helmrelease.Release)(nil), assert.AnError) + })).Return("", assert.AnError) // Create mock installer that succeeds (so we get to Helm charts) mockInstaller := &MockKotsCLIInstaller{} @@ -433,12 +432,12 @@ func TestComponentStatusTracking(t *testing.T) { // Database chart installation (should be first due to lower weight) mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { return opts.ReleaseName == "postgres" && opts.Namespace == "data" - })).Return(&helmrelease.Release{Name: "postgres"}, nil).Once() + })).Return("Release \"postgres\" has been installed.", nil).Once() // Web chart installation (should be second due to higher weight) mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { return opts.ReleaseName == "nginx" && opts.Namespace == "web" - })).Return(&helmrelease.Release{Name: "nginx"}, nil).Once() + })).Return("Release \"nginx\" has been installed.", nil).Once() // Create mock KOTS installer mockInstaller := &MockKotsCLIInstaller{} @@ -495,7 +494,7 @@ func TestComponentStatusTracking(t *testing.T) { mockHelmClient := &helm.MockClient{} mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { return opts.ReleaseName == "failing-app" - })).Return((*helmrelease.Release)(nil), errors.New("helm install failed")) + })).Return("", errors.New("helm install failed")) // Create mock installer that succeeds (so we get to Helm charts) mockInstaller := &MockKotsCLIInstaller{} diff --git a/api/internal/managers/app/install/manager.go b/api/internal/managers/app/install/manager.go index 00128cecae..5ed62ea5aa 100644 --- a/api/internal/managers/app/install/manager.go +++ b/api/internal/managers/app/install/manager.go @@ -12,7 +12,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" - "k8s.io/cli-runtime/pkg/genericclioptions" + helmcli "helm.sh/helm/v3/pkg/cli" ) var _ AppInstallManager = &appInstallManager{} @@ -32,17 +32,16 @@ type AppInstallManager interface { // appInstallManager is an implementation of the AppInstallManager interface type appInstallManager struct { - appInstallStore appinstallstore.Store - releaseData *release.ReleaseData - license []byte - clusterID string - airgapBundle string - kotsCLI KotsCLIInstaller - logger logrus.FieldLogger - hcli helm.Client - k8sVersion string - kubeConfigPath string - restClientGetter genericclioptions.RESTClientGetter + appInstallStore appinstallstore.Store + releaseData *release.ReleaseData + license []byte + clusterID string + airgapBundle string + kotsCLI KotsCLIInstaller + logger logrus.FieldLogger + hcli helm.Client + k8sVersion string + kubernetesEnvSettings *helmcli.EnvSettings } type AppInstallManagerOption func(*appInstallManager) @@ -102,15 +101,9 @@ func WithK8sVersion(k8sVersion string) AppInstallManagerOption { } } -func WithKubeConfigPath(path string) AppInstallManagerOption { +func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) AppInstallManagerOption { return func(m *appInstallManager) { - m.kubeConfigPath = path - } -} - -func WithRESTClientGetter(restClientGetter genericclioptions.RESTClientGetter) AppInstallManagerOption { - return func(m *appInstallManager) { - m.restClientGetter = restClientGetter + m.kubernetesEnvSettings = envSettings } } diff --git a/api/internal/managers/app/install/util.go b/api/internal/managers/app/install/util.go index b7d0165ce5..cb916c140f 100644 --- a/api/internal/managers/app/install/util.go +++ b/api/internal/managers/app/install/util.go @@ -32,10 +32,9 @@ func (m *appInstallManager) setupHelmClient() error { } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: m.kubeConfigPath, - RESTClientGetter: m.restClientGetter, - K8sVersion: m.k8sVersion, - LogFn: m.logFn("app-helm"), + KubernetesEnvSettings: m.kubernetesEnvSettings, + K8sVersion: m.k8sVersion, + LogFn: m.logFn("app-helm"), }) if err != nil { return fmt.Errorf("create helm client: %w", err) diff --git a/api/internal/managers/kubernetes/infra/manager.go b/api/internal/managers/kubernetes/infra/manager.go index 2cb2d3cb51..cdb2269c50 100644 --- a/api/internal/managers/kubernetes/infra/manager.go +++ b/api/internal/managers/kubernetes/infra/manager.go @@ -15,6 +15,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/sirupsen/logrus" + helmcli "helm.sh/helm/v3/pkg/cli" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" @@ -35,19 +36,19 @@ type KotsCLIInstaller interface { // infraManager is an implementation of the InfraManager interface type infraManager struct { - infraStore infrastore.Store - password string - tlsConfig types.TLSConfig - license []byte - airgapBundle string - releaseData *release.ReleaseData - endUserConfig *ecv1beta1.Config - logger logrus.FieldLogger - kcli client.Client - mcli metadata.Interface - hcli helm.Client - restClientGetter genericclioptions.RESTClientGetter - mu sync.RWMutex + infraStore infrastore.Store + password string + tlsConfig types.TLSConfig + license []byte + airgapBundle string + releaseData *release.ReleaseData + endUserConfig *ecv1beta1.Config + logger logrus.FieldLogger + kcli client.Client + mcli metadata.Interface + hcli helm.Client + kubernetesEnvSettings *helmcli.EnvSettings + mu sync.RWMutex } type InfraManagerOption func(*infraManager) @@ -118,9 +119,9 @@ func WithHelmClient(hcli helm.Client) InfraManagerOption { } } -func WithRESTClientGetter(restClientGetter genericclioptions.RESTClientGetter) InfraManagerOption { +func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) InfraManagerOption { return func(c *infraManager) { - c.restClientGetter = restClientGetter + c.kubernetesEnvSettings = envSettings } } @@ -140,8 +141,18 @@ func NewInfraManager(opts ...InfraManagerOption) (*infraManager, error) { manager.infraStore = infrastore.NewMemoryStore() } + // If none is provided, use the default env settings from helm + if manager.kubernetesEnvSettings == nil { + manager.kubernetesEnvSettings = helmcli.New() + } + + var restClientGetter genericclioptions.RESTClientGetter + if manager.kubernetesEnvSettings != nil { + restClientGetter = manager.kubernetesEnvSettings.RESTClientGetter() + } + if manager.kcli == nil { - kcli, err := clients.NewKubeClient(clients.KubeClientOptions{RESTClientGetter: manager.restClientGetter}) + kcli, err := clients.NewKubeClient(clients.KubeClientOptions{RESTClientGetter: restClientGetter}) if err != nil { return nil, fmt.Errorf("create kube client: %w", err) } @@ -149,7 +160,7 @@ func NewInfraManager(opts ...InfraManagerOption) (*infraManager, error) { } if manager.mcli == nil { - mcli, err := clients.NewMetadataClient(clients.KubeClientOptions{RESTClientGetter: manager.restClientGetter}) + mcli, err := clients.NewMetadataClient(clients.KubeClientOptions{RESTClientGetter: restClientGetter}) if err != nil { return nil, fmt.Errorf("create metadata client: %w", err) } @@ -158,7 +169,7 @@ func NewInfraManager(opts ...InfraManagerOption) (*infraManager, error) { if manager.hcli == nil { hcli, err := helm.NewClient(helm.HelmOptions{ - RESTClientGetter: manager.restClientGetter, + KubernetesEnvSettings: manager.kubernetesEnvSettings, // TODO: how can we support airgap? AirgapPath: "", LogFn: manager.logFn("helm"), diff --git a/api/internal/managers/kubernetes/infra/manager_test.go b/api/internal/managers/kubernetes/infra/manager_test.go index d708ca32d0..831986175f 100644 --- a/api/internal/managers/kubernetes/infra/manager_test.go +++ b/api/internal/managers/kubernetes/infra/manager_test.go @@ -7,6 +7,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + helmcli "helm.sh/helm/v3/pkg/cli" metadatafake "k8s.io/client-go/metadata/fake" "k8s.io/client-go/rest" "k8s.io/kubectl/pkg/scheme" @@ -16,81 +17,50 @@ import ( func TestNewInfraManager_ClientCreation(t *testing.T) { tests := []struct { name string - setupMock func(*clients.MockRESTClientGetter) withKubeClient bool withMetadataClient bool withHelmClient bool expectError bool }{ { - name: "creates all clients when none provided", - setupMock: func(mock *clients.MockRESTClientGetter) { - // kube client and metadata client creation - mock.On("ToRESTConfig").Return(&rest.Config{}, nil).Times(2) - }, + name: "creates all clients when none provided", expectError: false, }, { - name: "creates kube and metadata clients when helm client provided", - setupMock: func(mock *clients.MockRESTClientGetter) { - // kube client and metadata client creation - mock.On("ToRESTConfig").Return(&rest.Config{}, nil).Times(2) - }, + name: "creates kube and metadata clients when helm client provided", withHelmClient: true, expectError: false, }, { - name: "creates kube and helm clients when metadata client provided", - setupMock: func(mock *clients.MockRESTClientGetter) { - // kube client creation - mock.On("ToRESTConfig").Return(&rest.Config{}, nil).Times(1) - }, + name: "creates kube and helm clients when metadata client provided", withMetadataClient: true, expectError: false, }, { - name: "creates metadata and helm clients when kube client provided", - setupMock: func(mock *clients.MockRESTClientGetter) { - // metadata client creation - mock.On("ToRESTConfig").Return(&rest.Config{}, nil).Times(1) - }, + name: "creates metadata and helm clients when kube client provided", withKubeClient: true, expectError: false, }, { - name: "creates only helm client when kube and metadata clients provided", - setupMock: func(mock *clients.MockRESTClientGetter) { - // No ToRESTConfig calls expected - }, + name: "creates only helm client when kube and metadata clients provided", withKubeClient: true, withMetadataClient: true, expectError: false, }, { - name: "creates only metadata client when kube and helm clients provided", - setupMock: func(mock *clients.MockRESTClientGetter) { - // metadata client creation - mock.On("ToRESTConfig").Return(&rest.Config{}, nil).Times(1) - }, + name: "creates only metadata client when kube and helm clients provided", withKubeClient: true, withHelmClient: true, expectError: false, }, { - name: "creates only kube client when metadata and helm clients provided", - setupMock: func(mock *clients.MockRESTClientGetter) { - // kube client creation - mock.On("ToRESTConfig").Return(&rest.Config{}, nil).Times(1) - }, + name: "creates only kube client when metadata and helm clients provided", withMetadataClient: true, withHelmClient: true, expectError: false, }, { - name: "creates no clients when all provided", - setupMock: func(mock *clients.MockRESTClientGetter) { - // No ToRESTConfig calls expected - }, + name: "creates no clients when all provided", withKubeClient: true, withMetadataClient: true, withHelmClient: true, @@ -100,13 +70,9 @@ func TestNewInfraManager_ClientCreation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create mock RESTClientGetter - mockRestClientGetter := &clients.MockRESTClientGetter{} - tt.setupMock(mockRestClientGetter) - // Build options opts := []InfraManagerOption{ - WithRESTClientGetter(mockRestClientGetter), + WithKubernetesEnvSettings(helmcli.New()), } // Add pre-created clients if specified @@ -133,9 +99,6 @@ func TestNewInfraManager_ClientCreation(t *testing.T) { assert.NotNil(t, manager.kcli) assert.NotNil(t, manager.mcli) assert.NotNil(t, manager.hcli) - - // Verify mock expectations - mockRestClientGetter.AssertExpectations(t) }) } } @@ -168,7 +131,7 @@ func TestNewInfraManager_ToRESTConfigError(t *testing.T) { // Build options opts := []InfraManagerOption{ - WithRESTClientGetter(mockRestClientGetter), + WithKubernetesEnvSettings(helmcli.New()), } // Add pre-created clients if specified diff --git a/api/internal/managers/linux/infra/install.go b/api/internal/managers/linux/infra/install.go index 6c5019e970..51c8ddd925 100644 --- a/api/internal/managers/linux/infra/install.go +++ b/api/internal/managers/linux/infra/install.go @@ -176,7 +176,7 @@ func (m *infraManager) installK0s(ctx context.Context, rc runtimeconfig.RuntimeC } // initialize the manager's helm and kube clients - err = m.setupClients(rc.PathToKubeConfig(), rc.EmbeddedClusterChartsSubDir()) + err = m.setupClients(rc.GetKubernetesEnvSettings(), rc.EmbeddedClusterChartsSubDir()) if err != nil { return nil, fmt.Errorf("setup clients: %w", err) } diff --git a/api/internal/managers/linux/infra/util.go b/api/internal/managers/linux/infra/util.go index d4f7bb8494..24875422b9 100644 --- a/api/internal/managers/linux/infra/util.go +++ b/api/internal/managers/linux/infra/util.go @@ -11,6 +11,8 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" "github.com/replicatedhq/embedded-cluster/pkg/versions" + helmcli "helm.sh/helm/v3/pkg/cli" + "k8s.io/cli-runtime/pkg/genericclioptions" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -28,9 +30,14 @@ func (m *infraManager) waitForNode(ctx context.Context, kcli client.Client) erro // setupClients initializes the kube, metadata, and helm clients if they are not already set. // We need to do it after the infra manager is initialized to ensure that the runtime config is available and we already have a cluster setup -func (m *infraManager) setupClients(kubeConfigPath string, airgapChartsPath string) error { +func (m *infraManager) setupClients(kubernetesEnvSettings *helmcli.EnvSettings, airgapChartsPath string) error { + var restClientGetter genericclioptions.RESTClientGetter + if kubernetesEnvSettings != nil { + restClientGetter = kubernetesEnvSettings.RESTClientGetter() + } + if m.kcli == nil { - kcli, err := clients.NewKubeClient(clients.KubeClientOptions{KubeConfigPath: kubeConfigPath}) + kcli, err := clients.NewKubeClient(clients.KubeClientOptions{RESTClientGetter: restClientGetter}) if err != nil { return fmt.Errorf("create kube client: %w", err) } @@ -38,7 +45,7 @@ func (m *infraManager) setupClients(kubeConfigPath string, airgapChartsPath stri } if m.mcli == nil { - mcli, err := clients.NewMetadataClient(clients.KubeClientOptions{KubeConfigPath: kubeConfigPath}) + mcli, err := clients.NewMetadataClient(clients.KubeClientOptions{RESTClientGetter: restClientGetter}) if err != nil { return fmt.Errorf("create metadata client: %w", err) } @@ -50,11 +57,12 @@ func (m *infraManager) setupClients(kubeConfigPath string, airgapChartsPath stri if m.airgapBundle != "" { airgapPath = airgapChartsPath } + hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: kubeConfigPath, - K8sVersion: versions.K0sVersion, - AirgapPath: airgapPath, - LogFn: m.logFn("helm"), + KubernetesEnvSettings: kubernetesEnvSettings, + K8sVersion: versions.K0sVersion, + AirgapPath: airgapPath, + LogFn: m.logFn("helm"), }) if err != nil { return fmt.Errorf("create helm client: %w", err) diff --git a/api/types/api.go b/api/types/api.go index ad2c459f32..e704cbe8e9 100644 --- a/api/types/api.go +++ b/api/types/api.go @@ -6,7 +6,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/airgap" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" - "k8s.io/cli-runtime/pkg/genericclioptions" ) const ( @@ -40,6 +39,5 @@ type LinuxConfig struct { } type KubernetesConfig struct { - RESTClientGetter genericclioptions.RESTClientGetter - Installation kubernetesinstallation.Installation + Installation kubernetesinstallation.Installation } diff --git a/cmd/buildtools/metadata.go b/cmd/buildtools/metadata.go index f3c89eb576..4cef5fc101 100644 --- a/cmd/buildtools/metadata.go +++ b/cmd/buildtools/metadata.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "log" @@ -44,6 +45,7 @@ var metadataExtractHelmChartImagesCommand = &cli.Command{ charts := metadata.Configs.Charts hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH K8sVersion: metadata.Versions["Kubernetes"], }) if err != nil { @@ -51,7 +53,7 @@ var metadataExtractHelmChartImagesCommand = &cli.Command{ } defer hcli.Close() - images, err := extractImagesFromHelmExtensions(hcli, repos, charts) + images, err := extractImagesFromHelmExtensions(c.Context, hcli, repos, charts) if err != nil { return fmt.Errorf("failed to extract images from helm extensions: %w", err) } @@ -79,7 +81,7 @@ func readMetadataFromFile(path string) (*types.ReleaseMetadata, error) { return &metadata, nil } -func extractImagesFromHelmExtensions(hcli helm.Client, repos []k0sv1beta1.Repository, charts []embeddedclusterv1beta1.Chart) ([]string, error) { +func extractImagesFromHelmExtensions(ctx context.Context, hcli helm.Client, repos []k0sv1beta1.Repository, charts []embeddedclusterv1beta1.Chart) ([]string, error) { for _, entry := range repos { log.Printf("Adding helm repository %s", entry.Name) repo := &repo.Entry{ @@ -94,7 +96,7 @@ func extractImagesFromHelmExtensions(hcli helm.Client, repos []k0sv1beta1.Reposi if entry.Insecure != nil { repo.InsecureSkipTLSverify = *entry.Insecure } - err := hcli.AddRepo(repo) + err := hcli.AddRepo(ctx, repo) if err != nil { return nil, fmt.Errorf("add helm repository %s: %w", entry.Name, err) } diff --git a/cmd/buildtools/openebs.go b/cmd/buildtools/openebs.go index ce9e1fa659..087d44880d 100644 --- a/cmd/buildtools/openebs.go +++ b/cmd/buildtools/openebs.go @@ -59,7 +59,7 @@ var updateOpenEBSAddonCommand = &cli.Command{ logrus.Infof("using input override from INPUT_OPENEBS_CHART_VERSION: %s", nextChartVersion) } else { logrus.Infof("fetching the latest openebs chart version") - latest, err := LatestChartVersion(hcli, openebsRepo, "openebs") + latest, err := LatestChartVersion(c.Context, hcli, openebsRepo, "openebs") if err != nil { return fmt.Errorf("failed to get the latest openebs chart version: %v", err) } @@ -75,7 +75,7 @@ var updateOpenEBSAddonCommand = &cli.Command{ } logrus.Infof("mirroring openebs chart version %s", nextChartVersion) - if err := MirrorChart(hcli, openebsRepo, "openebs", nextChartVersion); err != nil { + if err := MirrorChart(c.Context, hcli, openebsRepo, "openebs", nextChartVersion); err != nil { return fmt.Errorf("failed to mirror openebs chart: %v", err) } diff --git a/cmd/buildtools/registry.go b/cmd/buildtools/registry.go index 5bfe312004..2e426816ad 100644 --- a/cmd/buildtools/registry.go +++ b/cmd/buildtools/registry.go @@ -41,7 +41,7 @@ var updateRegistryAddonCommand = &cli.Command{ } defer hcli.Close() - latest, err := LatestChartVersion(hcli, registryRepo, "docker-registry") + latest, err := LatestChartVersion(c.Context, hcli, registryRepo, "docker-registry") if err != nil { return fmt.Errorf("unable to get the latest registry version: %v", err) } @@ -54,7 +54,7 @@ var updateRegistryAddonCommand = &cli.Command{ } logrus.Infof("mirroring registry chart version %s", latest) - if err := MirrorChart(hcli, registryRepo, "docker-registry", latest); err != nil { + if err := MirrorChart(c.Context, hcli, registryRepo, "docker-registry", latest); err != nil { return fmt.Errorf("unable to mirror chart: %w", err) } diff --git a/cmd/buildtools/seaweedfs.go b/cmd/buildtools/seaweedfs.go index 39a846f97d..fb9ccb52f9 100644 --- a/cmd/buildtools/seaweedfs.go +++ b/cmd/buildtools/seaweedfs.go @@ -47,7 +47,7 @@ var updateSeaweedFSAddonCommand = &cli.Command{ logrus.Infof("using input override from INPUT_SEAWEEDFS_CHART_VERSION: %s", nextChartVersion) } else { logrus.Infof("fetching the latest seaweedfs chart version") - latest, err := LatestChartVersion(hcli, seaweedfsRepo, "seaweedfs") + latest, err := LatestChartVersion(c.Context, hcli, seaweedfsRepo, "seaweedfs") if err != nil { return fmt.Errorf("failed to get the latest seaweedfs chart version: %v", err) } @@ -63,7 +63,7 @@ var updateSeaweedFSAddonCommand = &cli.Command{ } logrus.Infof("mirroring seaweedfs chart version %s", nextChartVersion) - if err := MirrorChart(hcli, seaweedfsRepo, "seaweedfs", nextChartVersion); err != nil { + if err := MirrorChart(c.Context, hcli, seaweedfsRepo, "seaweedfs", nextChartVersion); err != nil { return fmt.Errorf("failed to mirror seaweedfs chart: %v", err) } diff --git a/cmd/buildtools/utils.go b/cmd/buildtools/utils.go index 3722825fbd..55761c28f3 100644 --- a/cmd/buildtools/utils.go +++ b/cmd/buildtools/utils.go @@ -342,14 +342,14 @@ func GetGreatestTagFromRegistry(ctx context.Context, ref string, constraints *se return bestStr, nil } -func LatestChartVersion(hcli helm.Client, repo *repo.Entry, name string) (string, error) { +func LatestChartVersion(ctx context.Context, hcli helm.Client, repo *repo.Entry, name string) (string, error) { logrus.Infof("adding helm repo %s", repo.Name) - err := hcli.AddRepo(repo) + err := hcli.AddRepo(ctx, repo) if err != nil { return "", fmt.Errorf("add helm repo: %w", err) } logrus.Infof("finding latest chart version of %s/%s", repo, name) - return hcli.Latest(repo.Name, name) + return hcli.Latest(ctx, repo.Name, name) } type DockerManifestNotFoundError struct { @@ -453,29 +453,29 @@ func RemoveTagFromImage(image string) string { return location } -func MirrorChart(hcli helm.Client, repo *repo.Entry, name, ver string) error { +func MirrorChart(ctx context.Context, hcli helm.Client, repo *repo.Entry, name, ver string) error { logrus.Infof("adding helm repo %s", repo.Name) - err := hcli.AddRepo(repo) + err := hcli.AddRepo(ctx, repo) if err != nil { return fmt.Errorf("add helm repo: %w", err) } logrus.Infof("pulling %s chart version %s", name, ver) - chpath, err := hcli.Pull(repo.Name, name, ver) + chpath, err := hcli.Pull(ctx, repo.Name, name, ver) if err != nil { return fmt.Errorf("pull chart %s: %w", name, err) } logrus.Infof("downloaded %s chart: %s", name, chpath) defer os.Remove(chpath) - srcMeta, err := hcli.GetChartMetadata(chpath) + srcMeta, err := hcli.GetChartMetadata(ctx, chpath) if err != nil { return fmt.Errorf("get source chart metadata: %w", err) } if val := os.Getenv("CHARTS_REGISTRY_SERVER"); val != "" { logrus.Infof("authenticating with %q", os.Getenv("CHARTS_REGISTRY_SERVER")) - if err := hcli.RegistryAuth( + if err := hcli.RegistryAuth(ctx, os.Getenv("CHARTS_REGISTRY_SERVER"), os.Getenv("CHARTS_REGISTRY_USER"), os.Getenv("CHARTS_REGISTRY_PASS"), @@ -487,7 +487,7 @@ func MirrorChart(hcli helm.Client, repo *repo.Entry, name, ver string) error { dst := fmt.Sprintf("oci://%s", os.Getenv("CHARTS_DESTINATION")) chartURL := fmt.Sprintf("%s/%s", dst, name) logrus.Infof("verifying if destination tag already exists") - dstMeta, err := helm.GetChartMetadata(hcli, chartURL, ver) + dstMeta, err := helm.GetChartMetadata(ctx, hcli, chartURL, ver) if err != nil && !strings.HasSuffix(err.Error(), "not found") { return fmt.Errorf("verify tag exists: %w", err) } else if err == nil { @@ -501,7 +501,7 @@ func MirrorChart(hcli helm.Client, repo *repo.Entry, name, ver string) error { logrus.Infof("destination tag does not exist") logrus.Infof("pushing %s chart to %s", name, dst) - if err := hcli.Push(chpath, dst); err != nil { + if err := hcli.Push(ctx, chpath, dst); err != nil { return fmt.Errorf("push %s chart: %w", name, err) } remote := fmt.Sprintf("%s/%s:%s", dst, name, ver) @@ -521,7 +521,7 @@ func NewHelm() (helm.Client, error) { return nil, fmt.Errorf("get k0s version: %w", err) } return helm.NewClient(helm.HelmOptions{ - Writer: logrus.New().Writer(), + HelmPath: "helm", // use the helm binary in PATH K8sVersion: sv.Original(), }) } diff --git a/cmd/buildtools/velero.go b/cmd/buildtools/velero.go index 0005475bcf..1d15dab0d4 100644 --- a/cmd/buildtools/velero.go +++ b/cmd/buildtools/velero.go @@ -77,7 +77,7 @@ var updateVeleroAddonCommand = &cli.Command{ logrus.Infof("using input override from INPUT_VELERO_CHART_VERSION: %s", nextChartVersion) } else { logrus.Infof("fetching the latest velero chart version") - latest, err := LatestChartVersion(hcli, veleroRepo, "velero") + latest, err := LatestChartVersion(c.Context, hcli, veleroRepo, "velero") if err != nil { return fmt.Errorf("failed to get the latest velero chart version: %v", err) } @@ -91,7 +91,7 @@ var updateVeleroAddonCommand = &cli.Command{ logrus.Infof("velero chart version is already up-to-date") } else { logrus.Infof("mirroring velero chart version %s", nextChartVersion) - if err := MirrorChart(hcli, veleroRepo, "velero", nextChartVersion); err != nil { + if err := MirrorChart(c.Context, hcli, veleroRepo, "velero", nextChartVersion); err != nil { return fmt.Errorf("failed to mirror velero chart: %v", err) } } diff --git a/cmd/installer/cli/enable_ha.go b/cmd/installer/cli/enable_ha.go index 80d04a64ee..caa2348905 100644 --- a/cmd/installer/cli/enable_ha.go +++ b/cmd/installer/cli/enable_ha.go @@ -80,9 +80,9 @@ func runEnableHA(ctx context.Context, rc runtimeconfig.RuntimeConfig) error { } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), + K8sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, }) if err != nil { return fmt.Errorf("unable to create helm client: %w", err) diff --git a/cmd/installer/cli/install.go b/cmd/installer/cli/install.go index 22d6414070..a9f56710ed 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -52,9 +52,9 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" helmcli "helm.sh/helm/v3/pkg/cli" - "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/discovery" "k8s.io/client-go/metadata" + "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -99,8 +99,6 @@ type installConfig struct { tlsCert tls.Certificate tlsCertBytes []byte tlsKeyBytes []byte - - kubernetesRESTClientGetter genericclioptions.RESTClientGetter } // webAssetsFS is the filesystem to be used by the web component. Defaults to nil allowing the web server to use the default assets embedded in the binary. Useful for testing. @@ -577,7 +575,7 @@ func preRunInstallLinux(cmd *cobra.Command, flags *InstallCmdFlags, rc runtimeco return nil } -func preRunInstallKubernetes(_ *cobra.Command, flags *InstallCmdFlags, _ kubernetesinstallation.Installation) error { +func preRunInstallKubernetes(_ *cobra.Command, flags *InstallCmdFlags, ki kubernetesinstallation.Installation) error { // TODO: we only support amd64 clusters for target=kubernetes installs helpers.SetClusterArch("amd64") @@ -590,7 +588,7 @@ func preRunInstallKubernetes(_ *cobra.Command, flags *InstallCmdFlags, _ kuberne } } - restConfig, err := flags.kubernetesEnvSettings.RESTClientGetter().ToRESTConfig() + restConfig, err := clientcmd.BuildConfigFromFlags("", flags.kubernetesEnvSettings.KubeConfig) if err != nil { return fmt.Errorf("failed to discover kubeconfig: %w", err) } @@ -605,7 +603,7 @@ func preRunInstallKubernetes(_ *cobra.Command, flags *InstallCmdFlags, _ kuberne return fmt.Errorf("failed to connect to kubernetes api server: %w", err) } - flags.kubernetesRESTClientGetter = flags.kubernetesEnvSettings.RESTClientGetter() + ki.SetKubernetesEnvSettings(flags.kubernetesEnvSettings) return nil } @@ -717,8 +715,7 @@ func runManagerExperienceInstall( AllowIgnoreHostPreflights: flags.ignoreHostPreflights, }, KubernetesConfig: apitypes.KubernetesConfig{ - RESTClientGetter: flags.kubernetesRESTClientGetter, - Installation: ki, + Installation: ki, }, }, @@ -802,9 +799,9 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), + K8sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, }) if err != nil { return fmt.Errorf("unable to create helm client: %w", err) diff --git a/cmd/installer/cli/join.go b/cmd/installer/cli/join.go index 0784f48a90..21629a7248 100644 --- a/cmd/installer/cli/join.go +++ b/cmd/installer/cli/join.go @@ -610,9 +610,9 @@ func maybeEnableHA(ctx context.Context, kcli client.Client, mcli metadata.Interf airgapChartsPath = rc.EmbeddedClusterChartsSubDir() } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), + K8sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, }) if err != nil { return fmt.Errorf("unable to create helm client: %w", err) diff --git a/cmd/installer/cli/restore.go b/cmd/installer/cli/restore.go index 6bd5c3e33b..648f0107ae 100644 --- a/cmd/installer/cli/restore.go +++ b/cmd/installer/cli/restore.go @@ -405,9 +405,9 @@ func runRestoreStepNew(ctx context.Context, appSlug, appTitle string, flags Inst } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), + K8sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, }) if err != nil { return fmt.Errorf("unable to create helm client: %w", err) @@ -612,9 +612,9 @@ func runRestoreEnableAdminConsoleHA(ctx context.Context, flags InstallCmdFlags, } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), + K8sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, }) if err != nil { return fmt.Errorf("create helm client: %w", err) @@ -710,9 +710,9 @@ func runRestoreExtensions(ctx context.Context, flags InstallCmdFlags, rc runtime } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), + K8sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, }) if err != nil { return fmt.Errorf("unable to create helm client: %w", err) diff --git a/cmd/installer/goods/materializer.go b/cmd/installer/goods/materializer.go index 8f4fcae08b..cfb119deef 100644 --- a/cmd/installer/goods/materializer.go +++ b/cmd/installer/goods/materializer.go @@ -54,6 +54,30 @@ func InternalBinary(name string) (string, error) { return dstpath.Name(), nil } +// Binary materializes a binary from inside bins directory +// and writes it to a tmp file. It returns the path to the materialized binary. +// The binary should be deleted after it is used. +// This is primarily intended for short-lived, internal-use binaries. +func Binary(name string) (string, error) { + srcpath := fmt.Sprintf("bins/%s", name) + srcfile, err := binfs.ReadFile(srcpath) + if err != nil { + return "", fmt.Errorf("unable to read asset: %w", err) + } + dstpath, err := os.CreateTemp("", fmt.Sprintf("embedded-cluster-%s-bin-", name)) + if err != nil { + return "", fmt.Errorf("unable to create temp file: %w", err) + } + defer dstpath.Close() + if _, err := dstpath.Write(srcfile); err != nil { + return "", fmt.Errorf("unable to write file: %w", err) + } + if err := dstpath.Chmod(0755); err != nil { + return "", fmt.Errorf("unable to set executable permissions: %w", err) + } + return dstpath.Name(), nil +} + // LocalArtifactMirrorUnitFile writes to disk the local-artifact-mirror systemd unit file. func (m *Materializer) LocalArtifactMirrorUnitFile() error { content, err := systemdfs.ReadFile("systemd/local-artifact-mirror.service") diff --git a/pkg-new/kubernetesinstallation/installation.go b/pkg-new/kubernetesinstallation/installation.go index 0e86e6b3d4..6940d5a2ab 100644 --- a/pkg-new/kubernetesinstallation/installation.go +++ b/pkg-new/kubernetesinstallation/installation.go @@ -5,6 +5,7 @@ import ( "github.com/replicatedhq/embedded-cluster/cmd/installer/goods" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + helmcli "helm.sh/helm/v3/pkg/cli" ) var _ Installation = &kubernetesInstallation{} @@ -16,8 +17,9 @@ type EnvSetter interface { } type kubernetesInstallation struct { - installation *ecv1beta1.KubernetesInstallation - envSetter EnvSetter + installation *ecv1beta1.KubernetesInstallation + envSetter EnvSetter + kubernetesEnvSettings *helmcli.EnvSettings } type osEnvSetter struct{} @@ -128,7 +130,17 @@ func (ki *kubernetesInstallation) SetProxySpec(proxySpec *ecv1beta1.ProxySpec) { ki.installation.Spec.Proxy = proxySpec } -// PathToEmbeddedBinary returns the path to an embedded binary by materializing it from the embedded assets. +// PathToEmbeddedBinary returns the path to the embedded binary. func (ki *kubernetesInstallation) PathToEmbeddedBinary(binaryName string) (string, error) { - return goods.InternalBinary(binaryName) + return goods.Binary(binaryName) +} + +// SetKubernetesEnvSettings sets the helm environment settings. +func (ki *kubernetesInstallation) SetKubernetesEnvSettings(envSettings *helmcli.EnvSettings) { + ki.kubernetesEnvSettings = envSettings +} + +// GetKubernetesEnvSettings returns the helm environment settings. +func (ki *kubernetesInstallation) GetKubernetesEnvSettings() *helmcli.EnvSettings { + return ki.kubernetesEnvSettings } diff --git a/pkg-new/kubernetesinstallation/interface.go b/pkg-new/kubernetesinstallation/interface.go index 73ab30670f..1147eb2700 100644 --- a/pkg-new/kubernetesinstallation/interface.go +++ b/pkg-new/kubernetesinstallation/interface.go @@ -2,6 +2,7 @@ package kubernetesinstallation import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + helmcli "helm.sh/helm/v3/pkg/cli" ) // Installation defines the interface for managing kubernetes installation @@ -24,4 +25,7 @@ type Installation interface { SetProxySpec(proxySpec *ecv1beta1.ProxySpec) PathToEmbeddedBinary(binaryName string) (string, error) + + SetKubernetesEnvSettings(envSettings *helmcli.EnvSettings) + GetKubernetesEnvSettings() *helmcli.EnvSettings } diff --git a/pkg-new/kubernetesinstallation/mock.go b/pkg-new/kubernetesinstallation/mock.go index 4a99037958..522cf42c12 100644 --- a/pkg-new/kubernetesinstallation/mock.go +++ b/pkg-new/kubernetesinstallation/mock.go @@ -3,6 +3,7 @@ package kubernetesinstallation import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/stretchr/testify/mock" + helmcli "helm.sh/helm/v3/pkg/cli" ) var _ Installation = (*MockInstallation)(nil) @@ -86,3 +87,17 @@ func (m *MockInstallation) PathToEmbeddedBinary(binaryName string) (string, erro args := m.Called(binaryName) return args.String(0), args.Error(1) } + +// SetKubernetesEnvSettings mocks the SetKubernetesEnvSettings method +func (m *MockInstallation) SetKubernetesEnvSettings(envSettings *helmcli.EnvSettings) { + m.Called(envSettings) +} + +// GetKubernetesEnvSettings mocks the GetKubernetesEnvSettings method +func (m *MockInstallation) GetKubernetesEnvSettings() *helmcli.EnvSettings { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).(*helmcli.EnvSettings) +} diff --git a/pkg/addons/adminconsole/integration/hostcabundle_test.go b/pkg/addons/adminconsole/integration/hostcabundle_test.go index 4c5e6ff6f0..41fc7f902c 100644 --- a/pkg/addons/adminconsole/integration/hostcabundle_test.go +++ b/pkg/addons/adminconsole/integration/hostcabundle_test.go @@ -27,7 +27,9 @@ func TestHostCABundle(t *testing.T) { err := os.WriteFile(addon.HostCABundlePath, []byte("test"), 0644) require.NoError(t, err, "Failed to write CA bundle file") - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/adminconsole/integration/kubernetes_test.go b/pkg/addons/adminconsole/integration/kubernetes_test.go index b079ddf0cd..42bd4b81f5 100644 --- a/pkg/addons/adminconsole/integration/kubernetes_test.go +++ b/pkg/addons/adminconsole/integration/kubernetes_test.go @@ -31,7 +31,9 @@ func TestKubernetes_Airgap(t *testing.T) { KotsInstaller: nil, } - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/adminconsole/integration/linux_test.go b/pkg/addons/adminconsole/integration/linux_test.go index 1b5f956b68..2b49a02757 100644 --- a/pkg/addons/adminconsole/integration/linux_test.go +++ b/pkg/addons/adminconsole/integration/linux_test.go @@ -45,7 +45,9 @@ func TestLinux_Airgap(t *testing.T) { err := os.WriteFile(addon.HostCABundlePath, []byte("test"), 0644) require.NoError(t, err, "Failed to write CA bundle file") - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go index 2ee9a572ef..2ad923b2e5 100644 --- a/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go +++ b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go @@ -28,7 +28,9 @@ func TestHostCABundle(t *testing.T) { HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", } - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/velero/integration/hostcabundle_test.go b/pkg/addons/velero/integration/hostcabundle_test.go index 3a0056472a..ddbd1b0c19 100644 --- a/pkg/addons/velero/integration/hostcabundle_test.go +++ b/pkg/addons/velero/integration/hostcabundle_test.go @@ -22,7 +22,9 @@ func TestHostCABundle(t *testing.T) { HostCABundlePath: "/etc/ssl/certs/ca-certificates.crt", } - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/addons/velero/integration/k0ssubdir_test.go b/pkg/addons/velero/integration/k0ssubdir_test.go index 90b78b9a38..0f9df2d44d 100644 --- a/pkg/addons/velero/integration/k0ssubdir_test.go +++ b/pkg/addons/velero/integration/k0ssubdir_test.go @@ -24,7 +24,9 @@ func TestK0sDir(t *testing.T) { K0sDataDir: k0sDir, } - hcli, err := helm.NewClient(helm.HelmOptions{}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + }) require.NoError(t, err, "NewClient should not return an error") err = addon.Install(context.Background(), t.Logf, nil, nil, hcli, ecv1beta1.Domains{}, nil) diff --git a/pkg/extensions/install.go b/pkg/extensions/install.go index e3e3a28612..30ebfe4e24 100644 --- a/pkg/extensions/install.go +++ b/pkg/extensions/install.go @@ -20,7 +20,7 @@ func Install(ctx context.Context, hcli helm.Client, progressChan chan<- Extensio return nil } - if err := addRepos(hcli, config.AdditionalRepositories()); err != nil { + if err := addRepos(ctx, hcli, config.AdditionalRepositories()); err != nil { return errors.Wrap(err, "add additional helm repositories") } diff --git a/pkg/extensions/upgrade.go b/pkg/extensions/upgrade.go index a6d17e34d3..5ebb360e14 100644 --- a/pkg/extensions/upgrade.go +++ b/pkg/extensions/upgrade.go @@ -25,7 +25,7 @@ type helmAction string func Upgrade(ctx context.Context, kcli client.Client, hcli helm.Client, prev *ecv1beta1.Installation, in *ecv1beta1.Installation) error { // add new helm repos if in.Spec.Config.Extensions.Helm != nil { - if err := addRepos(hcli, in.Spec.Config.Extensions.Helm.Repositories); err != nil { + if err := addRepos(ctx, hcli, in.Spec.Config.Extensions.Helm.Repositories); err != nil { return errors.Wrap(err, "add repos") } } diff --git a/pkg/extensions/util.go b/pkg/extensions/util.go index 16767f0fc4..6805140882 100644 --- a/pkg/extensions/util.go +++ b/pkg/extensions/util.go @@ -14,7 +14,7 @@ import ( helmrepo "helm.sh/helm/v3/pkg/repo" ) -func addRepos(hcli helm.Client, repos []k0sv1beta1.Repository) error { +func addRepos(ctx context.Context, hcli helm.Client, repos []k0sv1beta1.Repository) error { for _, r := range repos { logrus.Debugf("Adding helm repository %s", r.Name) @@ -30,7 +30,7 @@ func addRepos(hcli helm.Client, repos []k0sv1beta1.Repository) error { if r.Insecure != nil { helmRepo.InsecureSkipTLSverify = *r.Insecure } - if err := hcli.AddRepo(helmRepo); err != nil { + if err := hcli.AddRepo(ctx, helmRepo); err != nil { return errors.Wrapf(err, "add helm repository %s", r.Name) } } diff --git a/pkg/helm/binary_executor.go b/pkg/helm/binary_executor.go new file mode 100644 index 0000000000..edca0d236a --- /dev/null +++ b/pkg/helm/binary_executor.go @@ -0,0 +1,54 @@ +package helm + +import ( + "bytes" + "context" + "io" + + "github.com/replicatedhq/embedded-cluster/pkg/helpers" +) + +// BinaryExecutor is an interface for executing helm binary commands. +// This interface is mockable for testing purposes. +type BinaryExecutor interface { + // ExecuteCommand runs a command and returns stdout, stderr, and error + ExecuteCommand(ctx context.Context, env map[string]string, args ...string) (stdout string, stderr string, err error) +} + +// binaryExecutor implements BinaryExecutor using helpers.RunCommandWithOptions +type binaryExecutor struct { + bin string // Path to the binary to execute + logFn LogFn // Optional logging function +} + +// newBinaryExecutor creates a new binaryExecutor with the specified binary path +func newBinaryExecutor(bin string, logFn LogFn) BinaryExecutor { + return &binaryExecutor{bin: bin, logFn: logFn} +} + +// ExecuteCommand runs a command using helpers.RunCommandWithOptions and returns stdout, stderr, and error +func (c *binaryExecutor) ExecuteCommand(ctx context.Context, env map[string]string, args ...string) (string, string, error) { + var stdout, stderr bytes.Buffer + logWriter := &logWriter{logFn: c.logFn} + + err := helpers.RunCommandWithOptions(helpers.RunCommandOptions{ + Context: ctx, + Stdout: &stdout, + Stderr: io.MultiWriter(&stderr, logWriter), // Helm uses stderr for debug logging and progress + Env: env, + }, c.bin, args...) + + return stdout.String(), stderr.String(), err +} + +// logWriter wraps a logFn as an io.Writer +type logWriter struct { + logFn LogFn +} + +func (lw *logWriter) Write(p []byte) (n int, err error) { + if lw.logFn != nil && len(p) > 0 { + lw.logFn("%s", string(p)) + } + return len(p), nil +} diff --git a/pkg/helm/binary_executor_mock.go b/pkg/helm/binary_executor_mock.go new file mode 100644 index 0000000000..e88805caef --- /dev/null +++ b/pkg/helm/binary_executor_mock.go @@ -0,0 +1,20 @@ +package helm + +import ( + "context" + + "github.com/stretchr/testify/mock" +) + +var _ BinaryExecutor = (*MockBinaryExecutor)(nil) + +// MockBinaryExecutor is a mock implementation of BinaryExecutor for testing +type MockBinaryExecutor struct { + mock.Mock +} + +// ExecuteCommand mocks the ExecuteCommand method +func (m *MockBinaryExecutor) ExecuteCommand(ctx context.Context, env map[string]string, args ...string) (string, string, error) { + callArgs := m.Called(ctx, env, args) + return callArgs.String(0), callArgs.String(1), callArgs.Error(2) +} diff --git a/pkg/helm/binary_executor_test.go b/pkg/helm/binary_executor_test.go new file mode 100644 index 0000000000..25c0b9e8b0 --- /dev/null +++ b/pkg/helm/binary_executor_test.go @@ -0,0 +1,199 @@ +package helm + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_binaryExecutor_ExecuteCommand(t *testing.T) { + tests := []struct { + name string + bin string + args []string + wantErr bool + }{ + { + name: "echo command", + bin: "echo", + args: []string{"hello", "world"}, + wantErr: false, + }, + { + name: "invalid command", + bin: "nonexistent-command", + args: []string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + executor := newBinaryExecutor(tt.bin, nil) + stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, tt.args...) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Empty(t, stderr) + if tt.bin == "echo" { + assert.Contains(t, stdout, "hello world") + } + }) + } +} + +func Test_binaryExecutor_ExecuteCommand_WithLogging(t *testing.T) { + tests := []struct { + name string + bin string + args []string + wantErr bool + expectedStdout string + expectedStderr string + expectedLogs []string + }{ + { + name: "echo command with logging", + bin: "echo", + args: []string{"hello", "world"}, + wantErr: false, + expectedStdout: "hello world\n", + expectedStderr: "", + expectedLogs: []string{}, // No logs expected since echo only writes to stdout + }, + { + name: "command with stderr", + bin: "sh", + args: []string{"-c", "echo 'stdout message'; echo 'stderr message' >&2"}, + wantErr: false, + expectedStdout: "stdout message\n", + expectedStderr: "stderr message\n", + expectedLogs: []string{"stderr message\n"}, // Only stderr is logged + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var logs []string + logFn := func(format string, v ...any) { + logs = append(logs, fmt.Sprintf(format, v...)) + } + + executor := newBinaryExecutor(tt.bin, logFn) + stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, tt.args...) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + // Verify output is captured in buffers + assert.Equal(t, tt.expectedStdout, stdout) + assert.Equal(t, tt.expectedStderr, stderr) + + // Verify logging occurred with expected messages + assert.ElementsMatch(t, tt.expectedLogs, logs) + }) + } +} + +func Test_logWriter_Write(t *testing.T) { + var loggedMessages []string + logFn := func(format string, v ...any) { + loggedMessages = append(loggedMessages, format) + } + + writer := &logWriter{logFn: logFn} + + // Test writing data + n, err := writer.Write([]byte("test message")) + assert.NoError(t, err) + assert.Equal(t, 12, n) + assert.Len(t, loggedMessages, 1) + assert.Equal(t, "%s", loggedMessages[0]) + + // Test writing empty data + loggedMessages = nil + n, err = writer.Write([]byte{}) + assert.NoError(t, err) + assert.Equal(t, 0, n) + assert.Len(t, loggedMessages, 0) + + // Test with nil logFn + writer = &logWriter{logFn: nil} + n, err = writer.Write([]byte("test")) + assert.NoError(t, err) + assert.Equal(t, 4, n) +} + +func Test_MockBinaryExecutor_ExecuteCommand(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + env map[string]string + args []string + expectedStdout string + expectedStderr string + expectedErr error + }{ + { + name: "successful command", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, + map[string]string{"TEST": "value"}, + []string{"version"}, + ).Return("v3.12.0", "", nil) + }, + env: map[string]string{"TEST": "value"}, + args: []string{"version"}, + expectedStdout: "v3.12.0", + expectedStderr: "", + expectedErr: nil, + }, + { + name: "command with error", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, + mock.Anything, + []string{"invalid"}, + ).Return("", "command not found", assert.AnError) + }, + env: nil, + args: []string{"invalid"}, + expectedStdout: "", + expectedStderr: "command not found", + expectedErr: assert.AnError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockBinaryExecutor{} + tt.setupMock(mock) + + stdout, stderr, err := mock.ExecuteCommand(t.Context(), tt.env, tt.args...) + + if tt.expectedErr != nil { + assert.Error(t, err) + assert.Equal(t, tt.expectedStderr, stderr) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedStdout, stdout) + assert.Equal(t, tt.expectedStderr, stderr) + } + + mock.AssertExpectations(t) + }) + } +} diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 0478dc674b..8e98ef195c 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -1,62 +1,25 @@ package helm import ( - "bytes" "context" - "errors" + "encoding/json" "fmt" - "io" "os" "path/filepath" "strings" "time" "github.com/Masterminds/semver/v3" + "github.com/replicatedhq/embedded-cluster/cmd/installer/goods" "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" - "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" - "helm.sh/helm/v3/pkg/chart/loader" - "helm.sh/helm/v3/pkg/chartutil" - "helm.sh/helm/v3/pkg/downloader" - "helm.sh/helm/v3/pkg/getter" - "helm.sh/helm/v3/pkg/pusher" - "helm.sh/helm/v3/pkg/registry" + helmcli "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/release" - "helm.sh/helm/v3/pkg/releaseutil" "helm.sh/helm/v3/pkg/repo" - "helm.sh/helm/v3/pkg/storage/driver" - "helm.sh/helm/v3/pkg/uploader" - "k8s.io/cli-runtime/pkg/genericclioptions" - restclient "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - clientcmdapi "k8s.io/client-go/tools/clientcmd/api" k8syaml "sigs.k8s.io/yaml" ) -var ( - // getters is a list of known getters for both http and - // oci schemes. - getters = getter.Providers{ - getter.Provider{ - Schemes: []string{"http", "https"}, - New: getter.NewHTTPGetter, - }, - getter.Provider{ - Schemes: []string{"oci"}, - New: getter.NewOCIGetter, - }, - } - - // pushers holds all supported pushers (uploaders). - pushers = pusher.Providers{ - pusher.Provider{ - Schemes: []string{"oci"}, - New: pusher.NewOCIPusher, - }, - } -) - var _ Client = (*HelmClient)(nil) func newClient(opts HelmOptions) (*HelmClient, error) { @@ -64,10 +27,15 @@ func newClient(opts HelmOptions) (*HelmClient, error) { if err != nil { return nil, err } - registryOpts := []registry.ClientOption{} - if opts.Writer != nil { - registryOpts = append(registryOpts, registry.ClientOptWriter(opts.Writer)) + + helmPath := opts.HelmPath + if helmPath == "" { + helmPath, err = goods.Binary("helm") + if err != nil { + return nil, fmt.Errorf("get helm binary: %w", err) + } } + var kversion *semver.Version if opts.K8sVersion != "" { sv, err := semver.NewVersion(opts.K8sVersion) @@ -76,36 +44,28 @@ func newClient(opts HelmOptions) (*HelmClient, error) { } kversion = sv } - regcli, err := registry.NewClient(registryOpts...) - if err != nil { - return nil, fmt.Errorf("create registry client: %w", err) - } - if opts.RESTClientGetter == nil { - cfgFlags := &genericclioptions.ConfigFlags{} - if opts.KubeConfig != "" { - cfgFlags.KubeConfig = &opts.KubeConfig - } - opts.RESTClientGetter = cfgFlags - } + return &HelmClient{ - tmpdir: tmpdir, - kversion: kversion, - restClientGetter: opts.RESTClientGetter, - regcli: regcli, - logFn: opts.LogFn, - airgapPath: opts.AirgapPath, + helmPath: helmPath, + executor: newBinaryExecutor(helmPath, opts.LogFn), + tmpdir: tmpdir, + kversion: kversion, + kubernetesEnvSettings: opts.KubernetesEnvSettings, + airgapPath: opts.AirgapPath, + repositories: []*repo.Entry{}, }, nil } type HelmOptions struct { - KubeConfig string - RESTClientGetter genericclioptions.RESTClientGetter - K8sVersion string - AirgapPath string - Writer io.Writer - LogFn action.DebugLog + HelmPath string + KubernetesEnvSettings *helmcli.EnvSettings + K8sVersion string + AirgapPath string + LogFn LogFn } +type LogFn func(format string, v ...interface{}) + type InstallOptions struct { ReleaseName string ChartPath string @@ -135,48 +95,24 @@ type UninstallOptions struct { } type HelmClient struct { - tmpdir string - kversion *semver.Version - restClientGetter genericclioptions.RESTClientGetter - regcli *registry.Client - repocfg string - repos []*repo.Entry - reposChanged bool - logFn action.DebugLog - airgapPath string -} - -func (h *HelmClient) prepare() error { - // NOTE: this is a hack and should be refactored - if !h.reposChanged { - return nil - } - - data, err := k8syaml.Marshal(repo.File{Repositories: h.repos}) - if err != nil { - return fmt.Errorf("marshal repositories: %w", err) - } - - repocfg := filepath.Join(h.tmpdir, "config.yaml") - if err := os.WriteFile(repocfg, data, 0644); err != nil { - return fmt.Errorf("write repositories: %w", err) - } - - for _, repository := range h.repos { - chrepo, err := repo.NewChartRepository( - repository, getters, - ) - if err != nil { - return fmt.Errorf("create chart repo: %w", err) - } - chrepo.CachePath = h.tmpdir - _, err = chrepo.DownloadIndexFile() + helmPath string // Path to helm binary + executor BinaryExecutor // Mockable executor + tmpdir string // Temporary directory for helm + kversion *semver.Version // Kubernetes version for template rendering + kubernetesEnvSettings *helmcli.EnvSettings // Kubernetes environment settings + airgapPath string // Airgap path where charts are stored + repositories []*repo.Entry // Repository entries for helm repo commands +} + +func (h *HelmClient) prepare(ctx context.Context) error { + // Update all repositories to ensure we have the latest chart information + for _, repo := range h.repositories { + args := []string{"repo", "update", repo.Name} + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) if err != nil { - return fmt.Errorf("download index file: %w", err) + return fmt.Errorf("helm repo update %s: %w, stderr: %s", repo.Name, err, stderr) } } - h.repocfg = repocfg - h.reposChanged = false return nil } @@ -184,65 +120,66 @@ func (h *HelmClient) Close() error { return os.RemoveAll(h.tmpdir) } -func (h *HelmClient) AddRepo(repo *repo.Entry) error { - h.repos = append(h.repos, repo) - h.reposChanged = true - return nil -} +func (h *HelmClient) AddRepo(ctx context.Context, repo *repo.Entry) error { + // Use helm repo add command to add the repository + args := []string{"repo", "add", repo.Name, repo.URL} -func (h *HelmClient) Latest(reponame, chart string) (string, error) { - stableConstraint, err := semver.NewConstraint(">0.0.0") // search only for stable versions - if err != nil { - return "", fmt.Errorf("create stable constraint: %w", err) + // Add username/password if provided + if repo.Username != "" { + args = append(args, "--username", repo.Username) + } + if repo.Password != "" { + args = append(args, "--password", repo.Password) } - for _, repository := range h.repos { - if repository.Name != reponame { - continue - } - chrepo, err := repo.NewChartRepository(repository, getters) - if err != nil { - return "", fmt.Errorf("create chart repo: %w", err) - } - chrepo.CachePath = h.tmpdir - idx, err := chrepo.DownloadIndexFile() - if err != nil { - return "", fmt.Errorf("download index file: %w", err) - } + // Add insecure flag if needed + if repo.InsecureSkipTLSverify { + args = append(args, "--insecure-skip-tls-verify") + } - repoidx, err := repo.LoadIndexFile(idx) - if err != nil { - return "", fmt.Errorf("load index file: %w", err) - } + // Add pass-credentials flag if needed + if repo.PassCredentialsAll { + args = append(args, "--pass-credentials") + } - versions, ok := repoidx.Entries[chart] - if !ok { - return "", fmt.Errorf("chart %s not found", chart) - } + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + if err != nil { + return fmt.Errorf("helm repo add: %w, stderr: %s", err, stderr) + } - if len(versions) == 0 { - return "", fmt.Errorf("chart %s has no versions", chart) - } + // Store the repository entry for future reference + h.repositories = append(h.repositories, repo) + return nil +} - for _, version := range versions { - v, err := semver.NewVersion(version.Version) - if err != nil { - continue - } +func (h *HelmClient) Latest(ctx context.Context, reponame, chart string) (string, error) { + // Use helm search repo with JSON output to find the latest version + args := []string{"search", "repo", fmt.Sprintf("%s/%s", reponame, chart), "--version", ">0.0.0", "--versions", "--output", "json"} - if stableConstraint.Check(v) { - return version.Version, nil - } - } + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + if err != nil { + return "", fmt.Errorf("helm search repo: %w, stderr: %s", err, stderr) + } - return "", fmt.Errorf("no stable version found for chart %s", chart) + // Parse JSON output + var results []struct { + Version string `json:"version"` } - return "", fmt.Errorf("repository %s not found", reponame) + if err := json.Unmarshal([]byte(stdout), &results); err != nil { + return "", fmt.Errorf("parse helm search json output: %w", err) + } + + if len(results) == 0 { + return "", fmt.Errorf("no charts found for %s/%s", reponame, chart) + } + + // Return the version of the first result (latest version due to --versions flag) + return results[0].Version, nil } func (h *HelmClient) PullByRefWithRetries(ctx context.Context, ref string, version string, tries int) (string, error) { for i := 0; ; i++ { - localPath, err := h.PullByRef(ref, version) + localPath, err := h.PullByRef(ctx, ref, version) if err == nil { return localPath, nil } @@ -258,296 +195,436 @@ func (h *HelmClient) PullByRefWithRetries(ctx context.Context, ref string, versi } } -func (h *HelmClient) Pull(reponame, chart string, version string) (string, error) { +func (h *HelmClient) Pull(ctx context.Context, reponame, chart string, version string) (string, error) { ref := fmt.Sprintf("%s/%s", reponame, chart) - return h.PullByRef(ref, version) + return h.PullByRef(ctx, ref, version) } -func (h *HelmClient) PullByRef(ref string, version string) (string, error) { +func (h *HelmClient) PullByRef(ctx context.Context, ref string, version string) (string, error) { + // Update repositories if this is not an OCI chart if !isOCIChart(ref) { - if err := h.prepare(); err != nil { + if err := h.prepare(ctx); err != nil { return "", fmt.Errorf("prepare: %w", err) } } - dl := downloader.ChartDownloader{ - Out: io.Discard, - Options: []getter.Option{}, - RepositoryConfig: h.repocfg, - RepositoryCache: h.tmpdir, - Getters: getters, + // Use helm pull to download the chart + args := []string{"pull", ref} + if version != "" { + args = append(args, "--version", version) + } + args = append(args, "--destination", h.tmpdir) + + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") + + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + if err != nil { + return "", fmt.Errorf("helm pull: %w, stderr: %s", err, stderr) } - dst, _, err := dl.DownloadTo(ref, version, os.TempDir()) + // Get chart metadata to determine the actual chart name and construct filename + metadata, err := h.GetChartMetadata(ctx, ref) if err != nil { - return "", fmt.Errorf("download chart %s: %w", ref, err) + return "", fmt.Errorf("get chart metadata: %w", err) } - return dst, nil + // Construct expected filename (chart name + version + .tgz) + chartPath := filepath.Join(h.tmpdir, fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)) + + return chartPath, nil } -func (h *HelmClient) RegistryAuth(server, user, pass string) error { - return h.regcli.Login(server, registry.LoginOptBasicAuth(user, pass)) +func (h *HelmClient) RegistryAuth(ctx context.Context, server, user, pass string) error { + // Use helm registry login for authentication + args := []string{"registry", "login", server, "--username", user, "--password", pass} + + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + if err != nil { + return fmt.Errorf("helm registry login: %w, stderr: %s", err, stderr) + } + + return nil } -func (h *HelmClient) Push(path, dst string) error { - up := uploader.ChartUploader{ - Out: os.Stdout, - Pushers: pushers, - Options: []pusher.Option{pusher.WithRegistryClient(h.regcli)}, +func (h *HelmClient) Push(ctx context.Context, path, dst string) error { + // Use helm push to upload the chart + args := []string{"push", path, dst} + + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + if err != nil { + return fmt.Errorf("helm push: %w, stderr: %s", err, stderr) } - return up.UploadTo(path, dst) + return nil } -func (h *HelmClient) GetChartMetadata(chartPath string) (*chart.Metadata, error) { - chartRequested, err := loader.Load(chartPath) +func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string) (*chart.Metadata, error) { + // Use helm show chart to get chart metadata + args := []string{"show", "chart", ref} + + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) if err != nil { - return nil, fmt.Errorf("load chart: %w", err) + return nil, fmt.Errorf("helm show chart: %w, stderr: %s", err, stderr) } - return chartRequested.Metadata, nil + var metadata chart.Metadata + if err := k8syaml.Unmarshal([]byte(stdout), &metadata); err != nil { + return nil, fmt.Errorf("parse chart metadata YAML: %w", err) + } + return &metadata, nil } -// reference: https://github.com/helm/helm/blob/0d66425d9a745d8a289b1a5ebb6ccc744436da95/cmd/helm/upgrade.go#L122-L125 func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) { - cfg, err := h.getActionCfg(namespace) + // Use helm list to check if release exists + args := []string{"list", "--namespace", namespace, "--filter", fmt.Sprintf("^%s$", releaseName), "--output", "json"} + + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) if err != nil { - return false, fmt.Errorf("get action configuration: %w", err) + return false, fmt.Errorf("helm list: %w, stderr: %s", err, stderr) } - client := action.NewHistory(cfg) - client.Max = 1 - - versions, err := client.Run(releaseName) - if errors.Is(err, driver.ErrReleaseNotFound) || isReleaseUninstalled(versions) { - return false, nil + var releases []struct { + Status release.Status `json:"status"` } - if err != nil { - return false, fmt.Errorf("get release history: %w", err) + if err := json.Unmarshal([]byte(stdout), &releases); err != nil { + return false, fmt.Errorf("parse release list JSON: %w", err) } - return true, nil -} + // True if release exists and is not uninstalled + exists := len(releases) > 0 && releases[len(releases)-1].Status != release.StatusUninstalled -func isReleaseUninstalled(versions []*release.Release) bool { - return len(versions) > 0 && versions[len(versions)-1].Info.Status == release.StatusUninstalled + return exists, nil } -func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (*release.Release, error) { - cfg, err := h.getActionCfg(opts.Namespace) +// createValuesFile creates a temporary values file from the provided values map +func (h *HelmClient) createValuesFile(values map[string]interface{}) (string, error) { + if h.tmpdir == "" { + return "", fmt.Errorf("tmpdir not initialized") + } + + cleanVals, err := cleanUpGenericMap(values) if err != nil { - return nil, fmt.Errorf("get action configuration: %w", err) + return "", fmt.Errorf("clean up generic map: %w", err) } - client := action.NewInstall(cfg) - client.ReleaseName = opts.ReleaseName - client.Namespace = opts.Namespace - client.Labels = opts.Labels - client.Replace = true - client.CreateNamespace = true - client.WaitForJobs = true - client.Wait = true - // we don't set client.Atomic = true on install as it makes installation failures difficult to - // debug since it will rollback the release. + data, err := k8syaml.Marshal(cleanVals) + if err != nil { + return "", fmt.Errorf("marshal values: %w", err) + } - if opts.Timeout != 0 { - client.Timeout = opts.Timeout - } else { - client.Timeout = 5 * time.Minute + // Use unique filename to prevent race conditions + valuesFile := filepath.Join(h.tmpdir, fmt.Sprintf("values-%d.yaml", time.Now().UnixNano())) + if err := os.WriteFile(valuesFile, data, 0644); err != nil { + return "", fmt.Errorf("write values file: %w", err) } - chartRequested, err := h.loadChart(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) + return valuesFile, nil +} + +func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, error) { + // Build helm install command arguments + args := []string{"install", opts.ReleaseName} + + // Handle chart source + chartPath, err := h.resolveChartPath(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) if err != nil { - return nil, fmt.Errorf("load chart: %w", err) + return "", fmt.Errorf("resolve chart path: %w", err) + } + args = append(args, chartPath) + + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) + args = append(args, "--create-namespace") + } + + // Add wait options + args = append(args, "--wait") + args = append(args, "--wait-for-jobs") + + // Add timeout + timeout := opts.Timeout + if timeout == 0 { + timeout = 5 * time.Minute } + args = append(args, "--timeout", timeout.String()) - if req := chartRequested.Metadata.Dependencies; req != nil { - if err := action.CheckDependencies(chartRequested, req); err != nil { - return nil, fmt.Errorf("check chart dependencies: %w", err) + // Add replace flag + args = append(args, "--replace") + + // Add values if provided + if opts.Values != nil { + valuesFile, err := h.createValuesFile(opts.Values) + if err != nil { + return "", fmt.Errorf("create values file: %w", err) } + defer os.Remove(valuesFile) + args = append(args, "--values", valuesFile) } - cleanVals, err := cleanUpGenericMap(opts.Values) - if err != nil { - return nil, fmt.Errorf("clean up generic map: %w", err) + // Add labels if provided + if opts.Labels != nil { + var labelPairs []string + for k, v := range opts.Labels { + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v)) + } + args = append(args, "--labels", strings.Join(labelPairs, ",")) } - release, err := client.RunWithContext(ctx, chartRequested, cleanVals) + // Add kubeconfig and context if available + args = h.addKubernetesEnvArgs(args) + + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") + + // NOTE: we don't set client.Atomic = true on install as it makes installation failures difficult to debug + // since it will rollback the release. + + // Execute helm install command + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) if err != nil { - return nil, fmt.Errorf("helm install: %w", err) + return "", fmt.Errorf("helm install: %w, stderr: %s", err, stderr) } - return release, nil + return stdout, nil } -func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (*release.Release, error) { - cfg, err := h.getActionCfg(opts.Namespace) +// resolveChartPath handles chart source resolution for install, upgrade, and render operations +func (h *HelmClient) resolveChartPath(ctx context.Context, releaseName, chartPath, chartVersion string) (string, error) { + if h.airgapPath != "" { + // Use chart from airgap path + return filepath.Join(h.airgapPath, fmt.Sprintf("%s-%s.tgz", releaseName, chartVersion)), nil + } + if !strings.HasPrefix(chartPath, "/") { + // Pull chart with retries (includes oci:// prefix) + localPath, err := h.PullByRefWithRetries(ctx, chartPath, chartVersion, 3) + if err != nil { + return "", fmt.Errorf("pull chart: %w", err) + } + if localPath == "" { + return "", fmt.Errorf("pulled chart path is empty") + } + return localPath, nil + } + // Use local chart path + return chartPath, nil +} + +func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, error) { + // Build helm upgrade command arguments + args := []string{"upgrade", opts.ReleaseName} + + // Handle chart source + chartPath, err := h.resolveChartPath(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) if err != nil { - return nil, fmt.Errorf("get action configuration: %w", err) + return "", fmt.Errorf("resolve chart path: %w", err) } + args = append(args, chartPath) - client := action.NewUpgrade(cfg) - client.Namespace = opts.Namespace - client.Labels = opts.Labels - client.WaitForJobs = true - client.Wait = true - client.Atomic = true - client.Force = opts.Force + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) + } + + // Add wait options + args = append(args, "--wait") + args = append(args, "--wait-for-jobs") - if opts.Timeout != 0 { - client.Timeout = opts.Timeout - } else { - client.Timeout = 5 * time.Minute + // Add timeout + timeout := opts.Timeout + if timeout == 0 { + timeout = 5 * time.Minute } + args = append(args, "--timeout", timeout.String()) - chartRequested, err := h.loadChart(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) - if err != nil { - return nil, fmt.Errorf("load chart: %w", err) + // Add atomic flag + args = append(args, "--atomic") + + // Add force flag if specified + if opts.Force { + args = append(args, "--force") } - if req := chartRequested.Metadata.Dependencies; req != nil { - if err := action.CheckDependencies(chartRequested, req); err != nil { - return nil, fmt.Errorf("check chart dependencies: %w", err) + // Add values if provided + if opts.Values != nil { + valuesFile, err := h.createValuesFile(opts.Values) + if err != nil { + return "", fmt.Errorf("create values file: %w", err) } + defer os.Remove(valuesFile) + args = append(args, "--values", valuesFile) } - cleanVals, err := cleanUpGenericMap(opts.Values) - if err != nil { - return nil, fmt.Errorf("clean up generic map: %w", err) + // Add labels if provided + if opts.Labels != nil { + var labelPairs []string + for k, v := range opts.Labels { + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v)) + } + args = append(args, "--labels", strings.Join(labelPairs, ",")) } - release, err := client.RunWithContext(ctx, opts.ReleaseName, chartRequested, cleanVals) + // Add kubernetes environment arguments + args = h.addKubernetesEnvArgs(args) + + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") + + // Execute helm upgrade command + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) if err != nil { - return nil, fmt.Errorf("helm upgrade: %w", err) + return "", fmt.Errorf("helm upgrade: %w, stderr: %s", err, stderr) } - return release, nil + return stdout, nil } func (h *HelmClient) Uninstall(ctx context.Context, opts UninstallOptions) error { - cfg, err := h.getActionCfg(opts.Namespace) - if err != nil { - return fmt.Errorf("get action configuration: %w", err) + // Build helm uninstall command arguments + args := []string{"uninstall", opts.ReleaseName} + + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) + } + + // Add wait flag + if opts.Wait { + args = append(args, "--wait") + } + + // Add ignore not found flag + if opts.IgnoreNotFound { + args = append(args, "--ignore-not-found") } - client := action.NewUninstall(cfg) - client.Wait = opts.Wait - client.IgnoreNotFound = opts.IgnoreNotFound + // Add kubeconfig and context if available + args = h.addKubernetesEnvArgs(args) + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") + + // Add timeout from context if available if deadline, ok := ctx.Deadline(); ok { - client.Timeout = time.Until(deadline) + timeout := time.Until(deadline) + args = append(args, "--timeout", timeout.String()) } - if _, err := client.Run(opts.ReleaseName); err != nil { - return fmt.Errorf("uninstall release: %w", err) + // Execute helm uninstall command + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + if err != nil { + return fmt.Errorf("helm uninstall: %w, stderr: %s", err, stderr) } return nil } func (h *HelmClient) Render(ctx context.Context, opts InstallOptions) ([][]byte, error) { - cfg := &action.Configuration{} - - client := action.NewInstall(cfg) - client.DryRun = true - client.ReleaseName = opts.ReleaseName - client.Replace = true - client.CreateNamespace = true - client.ClientOnly = true - client.IncludeCRDs = true - client.Namespace = opts.Namespace - client.Labels = opts.Labels + // Build helm template command arguments + args := []string{"template", opts.ReleaseName} - if h.kversion != nil { - // since ClientOnly is true we need to initialize KubeVersion otherwise resorts defaults - client.KubeVersion = &chartutil.KubeVersion{ - Version: fmt.Sprintf("v%d.%d.0", h.kversion.Major(), h.kversion.Minor()), - Major: fmt.Sprintf("%d", h.kversion.Major()), - Minor: fmt.Sprintf("%d", h.kversion.Minor()), - } + // Handle chart source + chartPath, err := h.resolveChartPath(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) + if err != nil { + return nil, fmt.Errorf("resolve chart path: %w", err) } + args = append(args, chartPath) - chartRequested, err := h.loadChart(ctx, opts.ReleaseName, opts.ChartPath, opts.ChartVersion) - if err != nil { - return nil, fmt.Errorf("load chart: %w", err) + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) } - if req := chartRequested.Metadata.Dependencies; req != nil { - if err := action.CheckDependencies(chartRequested, req); err != nil { - return nil, fmt.Errorf("failed dependency check: %w", err) + // Add labels if provided + if opts.Labels != nil { + var labelPairs []string + for k, v := range opts.Labels { + labelPairs = append(labelPairs, fmt.Sprintf("%s=%s", k, v)) } + args = append(args, "--labels", strings.Join(labelPairs, ",")) } - cleanVals, err := cleanUpGenericMap(opts.Values) - if err != nil { - return nil, fmt.Errorf("clean up generic map: %w", err) + // Add values if provided + if opts.Values != nil { + valuesFile, err := h.createValuesFile(opts.Values) + if err != nil { + return nil, fmt.Errorf("create values file: %w", err) + } + defer os.Remove(valuesFile) + args = append(args, "--values", valuesFile) } - release, err := client.Run(chartRequested, cleanVals) - if err != nil { - return nil, fmt.Errorf("run render: %w", err) + // Add kubernetes version if available + if h.kversion != nil { + args = append(args, "--kube-version", fmt.Sprintf("v%d.%d.0", h.kversion.Major(), h.kversion.Minor())) } - var manifests bytes.Buffer - fmt.Fprintln(&manifests, strings.TrimSpace(release.Manifest)) - for _, m := range release.Hooks { - fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) - } + // Add kubeconfig and context if available + args = h.addKubernetesEnvArgs(args) - resources := [][]byte{} - splitManifests := releaseutil.SplitManifests(manifests.String()) - for _, manifest := range splitManifests { - manifest = strings.TrimSpace(manifest) - resources = append(resources, []byte(manifest)) - } + // Add include CRDs flag + args = append(args, "--include-crds") - return resources, nil -} + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") -func (h *HelmClient) getActionCfg(namespace string) (*action.Configuration, error) { - cfg := &action.Configuration{} - var logFn action.DebugLog - if h.logFn != nil { - logFn = h.logFn - } else { - logFn = _logFn - } - restClientGetter := &namespacedRESTClientGetter{ - RESTClientGetter: h.restClientGetter, - namespace: namespace, + // Execute helm template command + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + if err != nil { + return nil, fmt.Errorf("helm template: %w, stderr: %s", err, stderr) } - if err := cfg.Init(restClientGetter, namespace, "secret", logFn); err != nil { - return nil, fmt.Errorf("init helm configuration: %w", err) + + manifests, err := splitManifests(stdout) + if err != nil { + return nil, fmt.Errorf("parse helm template output: %w", err) } - return cfg, nil + return manifests, nil } -func (h *HelmClient) loadChart(ctx context.Context, releaseName, chartPath, chartVersion string) (*chart.Chart, error) { - var localPath string - if h.airgapPath != "" { - // airgapped, use chart from airgap path - // TODO: this should just respect the chart path if it's a local path and leave it up to the caller to handle - localPath = filepath.Join(h.airgapPath, fmt.Sprintf("%s-%s.tgz", releaseName, chartVersion)) - } else if !strings.HasPrefix(chartPath, "/") { - // Assume this is a chart from a repo if it doesn't start with a / - // This includes oci:// prefix - var err error - localPath, err = h.PullByRefWithRetries(ctx, chartPath, chartVersion, 3) - if err != nil { - return nil, fmt.Errorf("pull: %w", err) - } - defer os.RemoveAll(localPath) - } else { - localPath = chartPath +// addKubernetesEnvArgs adds kubernetes environment arguments to the helm command +func (h *HelmClient) addKubernetesEnvArgs(args []string) []string { + if h.kubernetesEnvSettings == nil { + return args } - chartRequested, err := loader.Load(localPath) - if err != nil { - return nil, fmt.Errorf("load: %w", err) + // Add all helm CLI flags from kubernetesEnvSettings + // Based on addKubernetesCLIFlags function + if h.kubernetesEnvSettings.KubeConfig != "" { + args = append(args, "--kubeconfig", h.kubernetesEnvSettings.KubeConfig) + } + if h.kubernetesEnvSettings.KubeContext != "" { + args = append(args, "--kube-context", h.kubernetesEnvSettings.KubeContext) + } + if h.kubernetesEnvSettings.KubeToken != "" { + args = append(args, "--kube-token", h.kubernetesEnvSettings.KubeToken) + } + if h.kubernetesEnvSettings.KubeAsUser != "" { + args = append(args, "--kube-as-user", h.kubernetesEnvSettings.KubeAsUser) + } + for _, group := range h.kubernetesEnvSettings.KubeAsGroups { + args = append(args, "--kube-as-group", group) + } + if h.kubernetesEnvSettings.KubeAPIServer != "" { + args = append(args, "--kube-apiserver", h.kubernetesEnvSettings.KubeAPIServer) + } + if h.kubernetesEnvSettings.KubeCaFile != "" { + args = append(args, "--kube-ca-file", h.kubernetesEnvSettings.KubeCaFile) + } + if h.kubernetesEnvSettings.KubeTLSServerName != "" { + args = append(args, "--kube-tls-server-name", h.kubernetesEnvSettings.KubeTLSServerName) + } + if h.kubernetesEnvSettings.KubeInsecureSkipTLSVerify { + args = append(args, "--kube-insecure-skip-tls-verify") + } + if h.kubernetesEnvSettings.BurstLimit != 0 { + args = append(args, "--burst-limit", fmt.Sprintf("%d", h.kubernetesEnvSettings.BurstLimit)) + } + if h.kubernetesEnvSettings.QPS != 0 { + args = append(args, "--qps", fmt.Sprintf("%.2f", h.kubernetesEnvSettings.QPS)) } - return chartRequested, nil + return args } func cleanUpGenericMap(m map[string]interface{}) (map[string]interface{}, error) { @@ -568,45 +645,3 @@ func cleanUpGenericMap(m map[string]interface{}) (map[string]interface{}, error) func isOCIChart(chartPath string) bool { return strings.HasPrefix(chartPath, "oci://") } - -func _logFn(format string, args ...interface{}) { - log := logrus.WithField("component", "helm") - log.Debugf(format, args...) -} - -type namespacedRESTClientGetter struct { - genericclioptions.RESTClientGetter - namespace string -} - -func (n *namespacedRESTClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { - cfg := n.RESTClientGetter.ToRawKubeConfigLoader() - return &namespacedClientConfig{ - cfg: cfg, - namespace: n.namespace, - } -} - -type namespacedClientConfig struct { - cfg clientcmd.ClientConfig - namespace string -} - -func (n *namespacedClientConfig) RawConfig() (clientcmdapi.Config, error) { - return n.cfg.RawConfig() -} - -func (n *namespacedClientConfig) ClientConfig() (*restclient.Config, error) { - return n.cfg.ClientConfig() -} - -func (n *namespacedClientConfig) Namespace() (string, bool, error) { - if n.namespace == "" { - return n.cfg.Namespace() - } - return n.namespace, true, nil -} - -func (n *namespacedClientConfig) ConfigAccess() clientcmd.ConfigAccess { - return n.cfg.ConfigAccess() -} diff --git a/pkg/helm/client_test.go b/pkg/helm/client_test.go index 7e3e7f6ece..b7c0930309 100644 --- a/pkg/helm/client_test.go +++ b/pkg/helm/client_test.go @@ -1,12 +1,360 @@ package helm import ( + "os" + "strings" "testing" + "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/repo" k8syaml "sigs.k8s.io/yaml" ) +func TestHelmClient_PullByRef(t *testing.T) { + tests := []struct { + name string + ref string + version string + repositories []*repo.Entry + setupMock func(*MockBinaryExecutor) + want string + wantErr bool + }{ + { + name: "successful pull with repository preparation", + ref: "myrepo/mychart", + version: "1.2.3", + repositories: []*repo.Entry{ + { + Name: "myrepo", + URL: "https://charts.example.com/myrepo", + }, + }, + setupMock: func(m *MockBinaryExecutor) { + // Mock helm repo update command (called by prepare()) + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + []string{"repo", "update", "myrepo"}, + ).Return("", "", nil) + + // Mock helm pull command + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.MatchedBy(func(args []string) bool { + return len(args) == 7 && + args[0] == "pull" && + args[1] == "myrepo/mychart" && + args[2] == "--version" && + args[3] == "1.2.3" && + args[4] == "--destination" && + // args[5] is the temp directory path, which varies + args[6] == "--debug" + }), + ).Return("", "", nil) + + // Mock helm show chart command for metadata + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + []string{"show", "chart", "myrepo/mychart"}, + ).Return(`apiVersion: v2 +name: mychart +description: A test chart from repo +type: application +version: 1.2.3 +appVersion: "1.0.0"`, "", nil) + }, + want: "mychart-1.2.3.tgz", + wantErr: false, + }, + { + name: "successful pull from OCI registry", + ref: "oci://registry.example.com/charts/nginx", + version: "2.1.0", + repositories: nil, // OCI charts don't use repositories + setupMock: func(m *MockBinaryExecutor) { + // No helm repo update for OCI charts (prepare() is skipped) + + // Mock helm pull command for OCI + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.MatchedBy(func(args []string) bool { + return len(args) == 7 && + args[0] == "pull" && + args[1] == "oci://registry.example.com/charts/nginx" && + args[2] == "--version" && + args[3] == "2.1.0" && + args[4] == "--destination" && + // args[5] is the temp directory path, which varies + args[6] == "--debug" + }), + ).Return("", "", nil) + + // Mock helm show chart command for metadata + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + []string{"show", "chart", "oci://registry.example.com/charts/nginx"}, + ).Return(`apiVersion: v2 +name: nginx +description: A nginx chart from OCI registry +type: application +version: 2.1.0 +appVersion: "1.25.0"`, "", nil) + }, + want: "nginx-2.1.0.tgz", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + // Create temporary directory for the test + tmpdir := t.TempDir() + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + tmpdir: tmpdir, + repositories: tt.repositories, + } + + got, err := client.PullByRef(t.Context(), tt.ref, tt.version) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + // Check that the returned path ends with the expected filename + assert.True(t, strings.HasSuffix(got, tt.want)) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_Install(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + opts InstallOptions + wantErr bool + }{ + { + name: "successful install", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + []string{"install", "myrelease", "/path/to/chart", "--namespace", "default", "--create-namespace", "--wait", "--wait-for-jobs", "--timeout", "5m0s", "--replace", "--debug"}, + ).Return(`Release "myrelease" has been upgraded.`, "", nil) + }, + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, + { + name: "install with values", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.MatchedBy(func(args []string) bool { + // Check that it contains the expected arguments + hasInstall := false + hasValues := false + for i, arg := range args { + if arg == "install" && i == 0 { + hasInstall = true + } + if arg == "--values" && i < len(args)-1 { + hasValues = true + } + } + return hasInstall && hasValues + }), + ).Return(`Release "myrelease" has been installed.`, "", nil) + }, + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + Values: map[string]interface{}{ + "key": "value", + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + // Create temporary directory for the test + tmpdir, err := os.MkdirTemp("", "helm-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpdir) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + tmpdir: tmpdir, + } + + stdout, err := client.Install(t.Context(), tt.opts) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.NotEmpty(t, stdout) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_ReleaseExists(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + namespace string + releaseName string + want bool + wantErr bool + }{ + { + name: "release exists", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + []string{"list", "--namespace", "default", "--filter", "^myrelease$", "--output", "json"}, + ).Return(`[{ + "name": "myrelease", + "namespace": "default", + "revision": 1, + "updated": "2023-01-01T00:00:00Z", + "status": "deployed", + "chart": "test-chart-1.0.0", + "app_version": "1.0.0" + }]`, "", nil) + }, + namespace: "default", + releaseName: "myrelease", + want: true, + wantErr: false, + }, + { + name: "release does not exist", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + []string{"list", "--namespace", "default", "--filter", "^myrelease$", "--output", "json"}, + ).Return(`[]`, "", nil) + }, + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + } + + exists, err := client.ReleaseExists(t.Context(), tt.namespace, tt.releaseName) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, exists) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_GetChartMetadata(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + chartPath string + wantErr bool + }{ + { + name: "successful metadata retrieval", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + []string{"show", "chart", "/path/to/chart"}, + ).Return(`apiVersion: v2 +name: test-chart +description: A test chart +type: application +version: 1.0.0 +appVersion: "1.0.0"`, "", nil) + }, + chartPath: "/path/to/chart", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + } + + metadata, err := client.GetChartMetadata(t.Context(), tt.chartPath) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, "test-chart", metadata.Name) + assert.Equal(t, "1.0.0", metadata.Version) + assert.Equal(t, "1.0.0", metadata.AppVersion) + mockExec.AssertExpectations(t) + }) + } +} + func Test_cleanUpGenericMap(t *testing.T) { tests := []struct { name string @@ -161,3 +509,93 @@ func Test_cleanUpGenericMap(t *testing.T) { }) } } + +func TestHelmClient_Latest(t *testing.T) { + tests := []struct { + name string + reponame string + chart string + setupMock func(*MockBinaryExecutor) + want string + wantErr bool + }{ + { + name: "valid JSON response", + reponame: "myrepo", + chart: "mychart", + setupMock: func(m *MockBinaryExecutor) { + jsonOutput := `[ + { + "name": "myrepo/mychart", + "version": "1.2.3", + "app_version": "1.2.3", + "description": "A test chart" + } + ]` + m.On("ExecuteCommand", mock.Anything, mock.Anything, + []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). + Return(jsonOutput, "", nil) + }, + want: "1.2.3", + wantErr: false, + }, + { + name: "empty results", + reponame: "myrepo", + chart: "nonexistent", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", mock.Anything, mock.Anything, + []string{"search", "repo", "myrepo/nonexistent", "--version", ">0.0.0", "--versions", "--output", "json"}). + Return("[]", "", nil) + }, + want: "", + wantErr: true, + }, + { + name: "helm command fails", + reponame: "myrepo", + chart: "mychart", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", mock.Anything, mock.Anything, + []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). + Return("", "repo not found", assert.AnError) + }, + want: "", + wantErr: true, + }, + { + name: "invalid JSON response", + reponame: "myrepo", + chart: "mychart", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", mock.Anything, mock.Anything, + []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). + Return("invalid json", "", nil) + }, + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + } + + got, err := client.Latest(t.Context(), tt.reponame, tt.chart) + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + mockExec.AssertExpectations(t) + }) + } +} diff --git a/pkg/helm/images.go b/pkg/helm/images.go index 94f9d8446d..f4f1ca48e9 100644 --- a/pkg/helm/images.go +++ b/pkg/helm/images.go @@ -68,14 +68,14 @@ func ExtractImagesFromChart(hcli Client, ref string, version string, values map[ return images, nil } -func GetChartMetadata(hcli Client, ref string, version string) (*chart.Metadata, error) { - chartPath, err := hcli.PullByRef(ref, version) +func GetChartMetadata(ctx context.Context, hcli Client, ref string, version string) (*chart.Metadata, error) { + chartPath, err := hcli.PullByRef(ctx, ref, version) if err != nil { return nil, fmt.Errorf("pull: %w", err) } defer os.RemoveAll(chartPath) - return hcli.GetChartMetadata(chartPath) + return hcli.GetChartMetadata(ctx, chartPath) } func extractImagesFromK8sManifest(resource []byte) ([]string, error) { diff --git a/pkg/helm/interface.go b/pkg/helm/interface.go index 5f90ba4aea..4a2d1d63c2 100644 --- a/pkg/helm/interface.go +++ b/pkg/helm/interface.go @@ -4,7 +4,6 @@ import ( "context" "helm.sh/helm/v3/pkg/chart" - "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/repo" ) @@ -14,16 +13,16 @@ var ( type Client interface { Close() error - AddRepo(repo *repo.Entry) error - Latest(reponame, chart string) (string, error) - Pull(reponame, chart string, version string) (string, error) - PullByRef(ref string, version string) (string, error) - RegistryAuth(server, user, pass string) error - Push(path, dst string) error - GetChartMetadata(chartPath string) (*chart.Metadata, error) + AddRepo(ctx context.Context, repo *repo.Entry) error + Latest(ctx context.Context, reponame, chart string) (string, error) + Pull(ctx context.Context, reponame, chart string, version string) (string, error) + PullByRef(ctx context.Context, ref string, version string) (string, error) + RegistryAuth(ctx context.Context, server, user, pass string) error + Push(ctx context.Context, path, dst string) error + GetChartMetadata(ctx context.Context, chartPath string) (*chart.Metadata, error) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) - Install(ctx context.Context, opts InstallOptions) (*release.Release, error) - Upgrade(ctx context.Context, opts UpgradeOptions) (*release.Release, error) + Install(ctx context.Context, opts InstallOptions) (string, error) + Upgrade(ctx context.Context, opts UpgradeOptions) (string, error) Uninstall(ctx context.Context, opts UninstallOptions) error Render(ctx context.Context, opts InstallOptions) ([][]byte, error) } diff --git a/pkg/helm/mock_client.go b/pkg/helm/mock_client.go index deeef6d68c..986d6d6f28 100644 --- a/pkg/helm/mock_client.go +++ b/pkg/helm/mock_client.go @@ -5,7 +5,6 @@ import ( "github.com/stretchr/testify/mock" "helm.sh/helm/v3/pkg/chart" - "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/repo" ) @@ -20,38 +19,38 @@ func (m *MockClient) Close() error { return args.Error(0) } -func (m *MockClient) AddRepo(repo *repo.Entry) error { - args := m.Called(repo) +func (m *MockClient) AddRepo(ctx context.Context, repo *repo.Entry) error { + args := m.Called(ctx, repo) return args.Error(0) } -func (m *MockClient) Latest(reponame, chart string) (string, error) { - args := m.Called(reponame, chart) +func (m *MockClient) Latest(ctx context.Context, reponame, chart string) (string, error) { + args := m.Called(ctx, reponame, chart) return args.String(0), args.Error(1) } -func (m *MockClient) Pull(reponame, chart string, version string) (string, error) { - args := m.Called(reponame, chart, version) +func (m *MockClient) Pull(ctx context.Context, reponame, chart string, version string) (string, error) { + args := m.Called(ctx, reponame, chart, version) return args.String(0), args.Error(1) } -func (m *MockClient) PullByRef(ref string, version string) (string, error) { - args := m.Called(ref, version) +func (m *MockClient) PullByRef(ctx context.Context, ref string, version string) (string, error) { + args := m.Called(ctx, ref, version) return args.String(0), args.Error(1) } -func (m *MockClient) RegistryAuth(server, user, pass string) error { - args := m.Called(server, user, pass) +func (m *MockClient) RegistryAuth(ctx context.Context, server, user, pass string) error { + args := m.Called(ctx, server, user, pass) return args.Error(0) } -func (m *MockClient) Push(path, dst string) error { - args := m.Called(path, dst) +func (m *MockClient) Push(ctx context.Context, path, dst string) error { + args := m.Called(ctx, path, dst) return args.Error(0) } -func (m *MockClient) GetChartMetadata(chartPath string) (*chart.Metadata, error) { - args := m.Called(chartPath) +func (m *MockClient) GetChartMetadata(ctx context.Context, chartPath string) (*chart.Metadata, error) { + args := m.Called(ctx, chartPath) if args.Get(0) == nil { return nil, args.Error(1) } @@ -63,20 +62,20 @@ func (m *MockClient) ReleaseExists(ctx context.Context, namespace string, releas return args.Bool(0), args.Error(1) } -func (m *MockClient) Install(ctx context.Context, opts InstallOptions) (*release.Release, error) { +func (m *MockClient) Install(ctx context.Context, opts InstallOptions) (string, error) { args := m.Called(ctx, opts) if args.Get(0) == nil { - return nil, args.Error(1) + return "", args.Error(1) } - return args.Get(0).(*release.Release), args.Error(1) + return args.Get(0).(string), args.Error(1) } -func (m *MockClient) Upgrade(ctx context.Context, opts UpgradeOptions) (*release.Release, error) { +func (m *MockClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, error) { args := m.Called(ctx, opts) if args.Get(0) == nil { - return nil, args.Error(1) + return "", args.Error(1) } - return args.Get(0).(*release.Release), args.Error(1) + return args.Get(0).(string), args.Error(1) } func (m *MockClient) Uninstall(ctx context.Context, opts UninstallOptions) error { diff --git a/pkg/helm/output_parser.go b/pkg/helm/output_parser.go new file mode 100644 index 0000000000..3245815284 --- /dev/null +++ b/pkg/helm/output_parser.go @@ -0,0 +1,26 @@ +package helm + +import ( + "regexp" + "strings" +) + +var separator = regexp.MustCompile(`(?:^|\n)\s*---\s*(?:\n|$)`) + +// splitManifests parses multi-doc YAML manifests and returns them as byte slices +func splitManifests(yamlOutput string) ([][]byte, error) { + result := [][]byte{} + + // Make sure that any extra whitespace in YAML stream doesn't interfere in splitting documents correctly. + manifests := separator.Split(strings.TrimSpace(yamlOutput), -1) + + for _, manifest := range manifests { + manifest = strings.TrimSpace(manifest) + if manifest == "" { + continue + } + result = append(result, []byte(manifest)) + } + + return result, nil +} diff --git a/pkg/helm/output_parser_test.go b/pkg/helm/output_parser_test.go new file mode 100644 index 0000000000..86be09497a --- /dev/null +++ b/pkg/helm/output_parser_test.go @@ -0,0 +1,150 @@ +package helm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_splitManifests(t *testing.T) { + tests := []struct { + name string + yamlInput string + want [][]byte + wantErr bool + }{ + { + name: "multiple YAML documents", + yamlInput: `apiVersion: v1 +kind: Service +metadata: + name: test-service +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment`, + want: [][]byte{ + []byte("apiVersion: v1\nkind: Service\nmetadata:\n name: test-service"), + []byte("apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: test-deployment"), + }, + wantErr: false, + }, + { + name: "single YAML document", + yamlInput: `apiVersion: v1 +kind: Service +metadata: + name: test-service`, + want: [][]byte{ + []byte("apiVersion: v1\nkind: Service\nmetadata:\n name: test-service"), + }, + wantErr: false, + }, + { + name: "empty input", + yamlInput: "", + want: [][]byte{}, + wantErr: false, + }, + { + name: "documents with whitespace around separators", + yamlInput: `apiVersion: v1 +kind: ConfigMap +metadata: + name: config1 + + --- + +apiVersion: v1 +kind: ConfigMap +metadata: + name: config2`, + want: [][]byte{ + []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: config1"), + []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: config2"), + }, + wantErr: false, + }, + { + name: "document starting with separator", + yamlInput: `--- +apiVersion: v1 +kind: Service +metadata: + name: test-service +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment`, + want: [][]byte{ + []byte("apiVersion: v1\nkind: Service\nmetadata:\n name: test-service"), + []byte("apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: test-deployment"), + }, + wantErr: false, + }, + { + name: "yaml content containing triple dash", + yamlInput: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +data: + message: "This contains --- in the middle but should not split here" +--- +apiVersion: v1 +kind: Secret +metadata: + name: test-secret`, + want: [][]byte{ + []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test-config\ndata:\n message: \"This contains --- in the middle but should not split here\""), + []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: test-secret"), + }, + wantErr: false, + }, + { + name: "complex whitespace variations", + yamlInput: ` apiVersion: v1 +kind: ConfigMap +metadata: + name: config1 + + --- + +apiVersion: v1 +kind: ConfigMap +metadata: + name: config2 + + --- + +apiVersion: v1 +kind: ConfigMap +metadata: + name: config3 `, + want: [][]byte{ + []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: config1"), + []byte("apiVersion: v1\nkind: ConfigMap \nmetadata:\n name: config2"), + []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: config3"), + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := splitManifests(tt.yamlInput) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, len(tt.want), len(got)) + for i, expected := range tt.want { + assert.Equal(t, string(expected), string(got[i])) + } + }) + } +} diff --git a/pkg/runtimeconfig/interface.go b/pkg/runtimeconfig/interface.go index fd98530474..8db028f027 100644 --- a/pkg/runtimeconfig/interface.go +++ b/pkg/runtimeconfig/interface.go @@ -2,6 +2,7 @@ package runtimeconfig import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + helmcli "helm.sh/helm/v3/pkg/cli" ) // RuntimeConfig defines the interface for managing runtime configuration @@ -47,4 +48,6 @@ type RuntimeConfig interface { SetProxySpec(proxySpec *ecv1beta1.ProxySpec) SetNetworkSpec(networkSpec ecv1beta1.NetworkSpec) SetHostCABundlePath(hostCABundlePath string) + + GetKubernetesEnvSettings() *helmcli.EnvSettings } diff --git a/pkg/runtimeconfig/mock.go b/pkg/runtimeconfig/mock.go index 36c3753d9a..035bf61441 100644 --- a/pkg/runtimeconfig/mock.go +++ b/pkg/runtimeconfig/mock.go @@ -3,6 +3,7 @@ package runtimeconfig import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/stretchr/testify/mock" + helmcli "helm.sh/helm/v3/pkg/cli" ) var _ RuntimeConfig = (*MockRuntimeConfig)(nil) @@ -221,3 +222,12 @@ func (m *MockRuntimeConfig) SetNetworkSpec(networkSpec ecv1beta1.NetworkSpec) { func (m *MockRuntimeConfig) SetHostCABundlePath(hostCABundlePath string) { m.Called(hostCABundlePath) } + +// GetKubernetesEnvSettings mocks the GetKubernetesEnvSettings method +func (m *MockRuntimeConfig) GetKubernetesEnvSettings() *helmcli.EnvSettings { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).(*helmcli.EnvSettings) +} diff --git a/pkg/runtimeconfig/runtimeconfig.go b/pkg/runtimeconfig/runtimeconfig.go index 9b67097af8..440813f63b 100644 --- a/pkg/runtimeconfig/runtimeconfig.go +++ b/pkg/runtimeconfig/runtimeconfig.go @@ -8,6 +8,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/helpers" "github.com/sirupsen/logrus" + helmcli "helm.sh/helm/v3/pkg/cli" "sigs.k8s.io/yaml" ) @@ -333,6 +334,14 @@ func (rc *runtimeConfig) SetHostCABundlePath(hostCABundlePath string) { rc.spec.HostCABundlePath = hostCABundlePath } +// GetKubernetesEnvSettings returns a minimal helm environment settings with just the kubeconfig path. +// For Linux target, this builds the settings from the runtime config kubeconfig path. +func (rc *runtimeConfig) GetKubernetesEnvSettings() *helmcli.EnvSettings { + envSettings := helmcli.New() + envSettings.KubeConfig = rc.PathToKubeConfig() + return envSettings +} + func mkdirAll(path string) error { return os.MkdirAll(path, 0755) } diff --git a/proposals/helm_binary_migration.md b/proposals/helm_binary_migration.md new file mode 100644 index 0000000000..f152e2a772 --- /dev/null +++ b/proposals/helm_binary_migration.md @@ -0,0 +1,365 @@ +# Helm Binary Migration Proposal + +## Executive Summary + +Replace the Helm Go SDK with direct helm binary execution for **all Embedded Cluster installs (V2 and V3)**. This approach aligns with KOTS' existing helm binary usage, reducing migration complexity and potential regressions when porting functionality from KOTS. + +## Problem Statement + +The current Helm Go SDK integration presents several challenges: +- **Migration Complexity**: Using the SDK instead of the binary adds complexity and potential for regressions when migrating from KOTS, which uses the helm binary directly. +- **Compatibility Issues**: SDK behavior may diverge from CLI behavior in edge cases. +- **Debugging Complexity**: SDK errors are harder to diagnose than CLI output. +- **Stability**: The Helm CLI interface seems to be more commonly used and robust than the SDK + +## Proposed Solution + +### Architecture Overview + +This proposal replaces the Helm Go SDK with direct binary execution while maintaining the exact same API interface. The change is transparent to all consumers and only affects the internal implementation. + +#### Current State (SDK-based) +``` +App/Installer → pkg/helm/interface.go → pkg/helm/client.go → Helm Go SDK → Kubernetes API +``` + +#### Proposed State (Binary-based) +``` +App/Installer → pkg/helm/interface.go → pkg/helm/client.go → helm binary → Kubernetes API +``` + +### Implementation Architecture + +**Application Layer (No Changes)** +• api/, cmd/embedded-cluster/, etc. +• All existing code continues to work unchanged + +↓ + +**Helm Interface (No Changes)** +• pkg/helm/interface.go maintains same Client interface +• Same method signatures, return types, and error handling + +↓ + +**Unified Binary Implementation:** +• pkg/helm/client.go (refactored to use helm binary) +• HelmClient struct (same name, different implementation) +• Command execution via helpers.RunCommand +• JSON output parsing with stdout/stderr capture +• Error handling and logging +• binaryExecutor interface (mockable for tests) +• Uses helm binary from cmd/installer/goods/materializer.go + +### Migration Strategy +**Single-phase migration**: Refactor existing `pkg/helm/client.go` to use binary execution instead of Go SDK for **both V2 and V3** installs. + +- Replace SDK calls with helm binary execution via helpers.RunCommand +- Maintain exact same public interface and behavior +- Helm binary availability handled by existing materializer functionality + +### Key Components + +#### 1. binaryExecutor Interface (Mockable) +```go +type binaryExecutor interface { + // ExecuteCommand runs a command and returns stdout, stderr, and error + ExecuteCommand(ctx context.Context, env map[string]string, bin string, args ...string) (stdout string, stderr string, err error) +} + +type commandExecutor struct{} + +func (c *commandExecutor) ExecuteCommand(ctx context.Context, env map[string]string, bin string, args ...string) (string, string, error) { + var stdoutBuf, stderrBuf bytes.Buffer + + err := helpers.RunCommandWithOptions(helpers.RunCommandOptions{ + Context: ctx, + Stdout: &stdoutBuf, + Stderr: &stderrBuf, + Env: env, + }, bin, args...) + + return stdoutBuf.String(), stderrBuf.String(), err +} +``` + +#### 2. HelmClient Structure (Refactored) +```go +type HelmClient struct { + helmPath string // Path to helm binary + executor binaryExecutor // Mockable executor + tmpdir string // Temporary directory for helm + kversion *semver.Version // Kubernetes version + restClientGetter genericclioptions.RESTClientGetter // REST client getter + registryConfig string // Registry config path for OCI + repositories []*repo.Entry // Repository entries + logFn action.DebugLog // Debug logging function + airgapPath string // Airgap path where charts are stored +} +``` + +## New Subagents / Commands + +**No new subagents or commands will be created.** This proposal only changes the internal implementation of the existing Helm client. + +## Database + +**No database changes required.** This proposal only affects in-memory operations and command execution. + +## Implementation plan + +### Files to Create/Modify + +#### New Files: +- `pkg/helm/binary_executor.go` - Executor interface and implementation (~100 lines) +- `pkg/helm/binary_executor_mock.go` - Generated mock for testing (~50 lines) +- `pkg/helm/output_parser.go` - Parse helm command outputs (~300 lines) +- `pkg/helm/output_parser_test.go` - Parser tests (~200 lines) + +#### Modified Files: +- `pkg/helm/client.go` - Complete refactor from SDK to binary execution (~800 lines, replacing 613 existing) +- `pkg/helm/client_test.go` - Update tests to use mock executor (~300 lines modified) +- `pkg/helm/values_test.go` - Update for binary client (~50 lines modified) +- `pkg/helm/interface.go` - No changes (same interface) + +#### Files Using Helm Client (No Changes Required): +- **70+ files** across codebase continue to work unchanged +- All addons, API managers, CLI commands, extensions maintain compatibility + +### Function to Binary Command Mapping + +| SDK Function | Helm Binary Command | Options Preserved | Output Parsing Required | +|--------------|-------------------|-------------------|------------------------| +| `Install()` | `helm install` | ✓ All | Release JSON | +| `Upgrade()` | `helm upgrade` | ✓ All including `--force` | Release JSON | +| `Uninstall()` | `helm uninstall` | ✓ `--wait`, `--no-hooks` | Success message | +| `ReleaseExists()` | `helm list` | `--namespace`, `--filter` | JSON list | +| `Render()` | `helm template` | ✓ All options | YAML manifests | +| `Pull()` | `helm pull` | `--version`, `--repo` | File path | +| `PullByRef()` | `helm pull` | `--version` for OCI | File path | +| `Push()` | `helm push` | OCI destination | Success message | +| `RegistryAuth()` | `helm registry login` | `--username`, `--password` | Success message | +| `AddRepo()` | `helm repo add` | `--force-update`, auth | Success message | +| `Latest()` | `helm search repo` | `--version ">0.0.0"` | Version string | +| `GetChartMetadata()` | `helm show chart` | Chart path | Chart.yaml parsing | + +### Detailed Option Preservation + +#### Install Options +```bash +helm install [NAME] [CHART] \ + --namespace \ + --create-namespace \ + --wait \ + --wait-for-jobs \ + --timeout \ + --values \ + --set key=value \ + --atomic=false \ # Explicitly false for install + --replace \ + --output json +``` + +#### Upgrade Options +```bash +helm upgrade [NAME] [CHART] \ + --namespace \ + --wait \ + --wait-for-jobs \ + --timeout \ + --values \ + --set key=value \ + --atomic \ + --force \ # Critical: User noticed this was missing + --output json +``` + +#### Uninstall Options +```bash +helm uninstall [NAME] \ + --namespace \ + --wait \ + --timeout \ + --ignore-not-found +``` + +### Implementation + +```go +// Example: Install implementation +func (c *HelmClient) Install(ctx context.Context, opts InstallOptions) (*release.Release, error) { + args := []string{"install", opts.ReleaseName} + + // Handle chart source + if c.airgapPath != "" { + // Use chart from airgap path + } else if !strings.HasPrefix(opts.ChartPath, "/") { + // Pull chart with retries (includes oci:// prefix) + } else { + // Use local chart path + } + + // Add all helm install flags: --namespace, --create-namespace, --wait, etc. + // Add values file if provided + // Add labels if provided + + // Execute helm command + stdout, stderr, err := c.executor.ExecuteCommand(ctx, nil, c.helmPath, args...) + + // Parse release from JSON output + return &release, nil +} + +// Example: ReleaseExists implementation +func (c *HelmClient) ReleaseExists(ctx context.Context, namespace, name string) (bool, error) { + // Build: helm list --namespace X --filter "^name$" --output json + // Execute command and parse JSON list + // Check if release exists and is not uninstalled + return exists, nil +} +``` + +### External Contracts + +No changes to external APIs. The binary implementation maintains exact compatibility with existing interface. + +## Testing + +### Unit Tests +```go +// Using mockery-generated mock +func TestHelmClient_Install(t *testing.T) { + mockExec := new(MockBinaryExecutor) + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + } + + mockExec.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + "/usr/local/bin/helm", + "install", "myrelease", "/path/to/chart", + "--namespace", "default", + "--create-namespace", + "--wait", + "--wait-for-jobs", + "--timeout", "5m0s", + "--replace", + "--output", "json", + ).Return(testReleaseJSON, "", nil) + + release, err := client.Install(context.Background(), InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + }) + + require.NoError(t, err) + assert.Equal(t, "myrelease", release.Name) + mockExec.AssertExpectations(t) +} +``` + +### Integration Tests +- Execution with SDK and binary implementations +- Output comparison for all operations +- Airgap mode testing + +### Test Data and Fixtures +- Sample chart archives +- Mock release JSON outputs +- Error response samples +- Repository index files + +## Backward compatibility + +### Full API Compatibility +- Exact same Client interface maintained +- All return types preserved +- No changes to function signatures + +### Data Format Compatibility +- JSON output parsing for structured data +- YAML manifest compatibility for Render() +- Repository cache format unchanged + +## Migrations + +**Helm binary must be embedded in installer.** The existing materializer functionality in `cmd/installer/goods/materializer.go` will handle helm binary availability similar to other binaries. + +### Required Changes: +1. **Embed helm binary** in the embedded-cluster installer binary +2. **Materialize helm binary** during installation to same directory we materialize other embedded binaries +3. **Enable binary client** for all installs (v2 and v3) +4. **Maintain exact same interface** for all consuming code + +### Implementation: +- Verify helm binary is materialized during installation +- Replace all SDK calls with `helpers.RunCommand` execution +- Parse command outputs to maintain existing return types + +## Trade-offs + +### Optimizing For: +- **Maintainability**: Simpler codebase without SDK dependencies +- **Compatibility**: Guaranteed parity with helm CLI behavior +- **Debuggability**: Clear command output in logs + +## Alternative solutions considered + +### 1. Upgrade Helm SDK to Latest Version +- **Rejected**: Continues maintenance burden, doesn't solve core issues +- **Risk**: Breaking changes in SDK API + +### 2. Fork Helm SDK +- **Rejected**: Massive maintenance burden +- **Risk**: Divergence from upstream + +### 4. Hybrid Approach (SDK for some, binary for others) +- **Rejected**: Would require maintaining both SDK and binary implementations +- **Complexity**: + - Need to carefully track which functions use which implementation + - More complex testing matrix to validate both paths + - Increased cognitive load for developers to remember which path to use + - Potential for subtle bugs when functions interact across implementations + +## Research + +### Prior Art in Codebase +- [Helm Binary Migration Research](./helm_binary_migration_research.md) +- `pkg/helpers/RunCommand` - Established pattern for command execution +- `pkg/helpers/firewalld/client.go` - Example of binary wrapper pattern +- Mock patterns in `pkg/helpers/mock.go` + +### External References +- [Helm CLI Documentation](https://helm.sh/docs/helm/) +- [Kubernetes SIG-Apps Helm discussions](https://github.com/kubernetes/community/tree/master/sig-apps) +- [ArgoCD Helm Binary Integration](https://github.com/argoproj/argo-cd/tree/master/util/helm) +- [Flux Helm Controller](https://github.com/fluxcd/helm-controller) - Uses helm SDK but considering binary + +### Prototypes and Learnings +- Spike: JSON output parsing - All commands support --output json +- Spike: Concurrent execution - No file lock issues with separate processes +- Test: Repository cache compatibility verified between SDK and binary + +## Checkpoints (PR plan) + +### PR 1: Foundation & Utilities +- `pkg/helm/binary_executor.go` - Interface and implementation +- Generate `pkg/helm/binary_executor_mock.go` using github.com/stretchr/testify/mock +- `pkg/helm/output_parser.go` - Parse JSON and YAML outputs from helm commands +- Unit tests for executor and parser components + +### PR 2: Client Refactor +- Complete refactor of `pkg/helm/client.go` - replace SDK with binary execution +- All 13 interface methods implemented with binary commands +- Comprehensive error handling with stdout/stderr capture and logging +- Update `pkg/helm/client_test.go` to use mock executor +- Update `pkg/helm/values_test.go` for binary client +- Remove unused Helm Go SDK imports and dependencies + +Each PR will include: +- Complete implementation for its scope +- Unit and integration tests diff --git a/proposals/helm_binary_migration_research.md b/proposals/helm_binary_migration_research.md new file mode 100644 index 0000000000..ce96f62a24 --- /dev/null +++ b/proposals/helm_binary_migration_research.md @@ -0,0 +1,427 @@ +--- +date: 2025-08-28T21:30:00-07:00 +researcher: claude-code +git_commit: 7e03295e +branch: salah/sc-128060/add-missing-functionality-for-the-image-pull +repository: replicatedhq/embedded-cluster +topic: "Helm Client Usage Analysis for Go SDK to Binary Migration" +tags: [research, codebase, helm, migration, v2, v3] +status: complete +last_updated: 2025-08-28 +last_updated_by: claude-code +--- + +# Helm Binary Migration Research + +**Date**: 2025-08-28T21:30:00-07:00 +**Researcher**: claude-code +**Git Commit**: 7e03295e +**Branch**: salah/sc-128060/add-missing-functionality-for-the-image-pull +**Repository**: replicatedhq/embedded-cluster + +## Research Question +Analyze the current Helm client usage across the entire embedded-cluster codebase to understand the scope of migrating from Helm Go SDK to Helm binary for both v2 and v3. Focus on understanding what needs to change when we refactor the existing client.go to use binary execution instead of the Go SDK. + +## Executive Summary +The embedded-cluster codebase has extensive Helm usage across **70 files** with a well-defined interface and complex dependency patterns. The migration scope includes **613 lines** in the core client implementation, **32 test files** with mocking, and critical usage across all major components including addons, extensions, API managers, and CLI operations. The analysis reveals clear v2/v3 usage patterns and identifies **3 critical Helm Go SDK types** that must be preserved in the interface. + +## Core Implementation Analysis + +### pkg/helm/client.go (613 lines) +**Primary implementation**: Complete Helm v3 Go SDK wrapper +- **Interface**: `pkg/helm/interface.go` defines the `Client` interface with 13 methods +- **Dependencies**: 70 files across the codebase depend on the Helm package + +**Key Helm SDK dependencies** (15 imports from `helm.sh/helm/v3/pkg/*`): +- `action` - Install, Upgrade, Uninstall, History, Configuration +- `chart` - Chart metadata and loading (`chart.Metadata`, `chart.Chart`) +- `release` - Release management (`release.Release`, `release.Status`) +- `repo` - Repository management (`repo.Entry`, `repo.File`) +- `downloader` - Chart downloading (`downloader.ChartDownloader`) +- `registry` - OCI registry support (`registry.Client`) +- `getter` - Chart fetching (`getter.Providers`) +- `pusher` - Chart uploading (`pusher.Providers`) + +### pkg/helm/interface.go (43 lines) +**Client interface**: 13 methods defining the complete Helm contract +- **Factory pattern**: ClientFactory with SetClientFactory for dependency injection +- **Critical method signatures**: + - `Install(ctx, InstallOptions) (*release.Release, error)` + - `Upgrade(ctx, UpgradeOptions) (*release.Release, error)` + - `Render(ctx, InstallOptions) ([][]byte, error)` + - `GetChartMetadata(chartPath) (*chart.Metadata, error)` + +## File Usage Distribution + +### Direct Helm Package Consumers (70 files) + +#### Addons (30 files): All infrastructure components +- **Components**: openebs, velero, seaweedfs, registry, embeddedclusteroperator, adminconsole +- **Pattern**: Each addon has install.go, upgrade.go, metadata.go, values.go +- **Usage**: Direct calls to helm.Client for installing/upgrading cluster components + +#### API Managers (8 files): V3 application deployment and infrastructure +- **Location**: `api/internal/managers/app/` +- **Purpose**: Deploy customer applications via Helm charts +- **Features**: Template rendering, install manager, release management + +#### CLI Commands (4 files): install, join, restore, enable_ha +- **Install Command**: `cmd/installer/cli/install.go` +- **Join Command**: `cmd/installer/cli/join.go` +- **Restore Command**: `cmd/installer/cli/restore.go` +- **Enable HA**: `cmd/installer/cli/enable_ha.go` + +#### Extensions (3 files): Third-party extension management +- **Location**: `pkg/extensions/` +- **Purpose**: Install and upgrade third-party extensions + +#### Build Tools (7 files): Chart packaging for airgap bundles +- **Location**: `cmd/buildtools/` +- **Purpose**: Pull and package charts for airgap bundles +- **Components**: velero.go, seaweedfs.go, registry.go, openebs.go, embeddedclusteroperator.go, adminconsole.go + +#### Operator (2 files): Automated upgrade jobs +- **Location**: `operator/pkg/upgrade/upgrade.go`, `operator/pkg/cli/upgrade_job.go` +- **Purpose**: Automated upgrades of cluster components + +#### Tests (32 files): Integration and dryrun tests +- **Unit Tests**: Mock implementations in tests/dryrun/ +- **Integration Tests**: tests/integration/util/helm.go +- **Test Patterns**: Heavy use of mock.Mock for helm.Client + +### Helm SDK Direct Imports (16 files) +Key files that directly import `helm.sh/helm/v3/pkg/*`: +- `pkg/helm/client.go` - Core implementation +- `pkg/helm/interface.go` - Type definitions +- `pkg/helm/mock_client.go` - Test mocking +- `api/internal/managers/app/release/util.go` - Release utilities +- `cmd/buildtools/*.go` - Chart build tools + +## Helm Operations Analysis + +### Current SDK Operations +The current implementation uses Helm v3 Go SDK for: + +#### 1. Release Management Operations +- **Install** - 30+ usage sites across addons and applications + - Pattern: `hcli.Install(ctx, helm.InstallOptions{...})` + - Return: `*release.Release` with complete release metadata + +- **Upgrade** - 25+ usage sites for component updates + - Pattern: `hcli.Upgrade(ctx, helm.UpgradeOptions{...})` + - Critical option: `Force: true` for upgrades + +- **Uninstall** - 10+ usage sites for cleanup operations + - Pattern: `hcli.Uninstall(ctx, helm.UninstallOptions{...})` + - Options: `Wait`, `IgnoreNotFound` + +- **ReleaseExists** - 15+ usage sites for state checking + - Pattern: `exists, err := hcli.ReleaseExists(ctx, namespace, name)` + - Critical for upgrade/install decision logic + +#### 2. Chart Management Operations +- **Pull/PullByRef** - 20+ usage sites for chart downloading + - Supports both traditional repos and OCI registries + - Retry logic with `PullByRefWithRetries` + +- **Render** - 10+ usage sites for template rendering + - Pattern: `manifests, err := hcli.Render(ctx, opts)` + - Returns `[][]byte` of rendered YAML manifests + +- **GetChartMetadata** - 8+ usage sites for metadata extraction + - Returns `*chart.Metadata` with version, dependencies info + +#### 3. Repository Management +- **AddRepo** - Add Helm repositories +- **RegistryAuth** - Authenticate to OCI registries +- **Latest** - Find latest stable chart version + +## Critical Use Cases + +### 1. Addon Installation (Core Infrastructure) +**Files**: All addon packages (openebs, velero, seaweedfs, registry, embeddedclusteroperator, adminconsole) +- **Pattern**: Each addon has install.go, upgrade.go, metadata.go, values.go +- **Usage**: Direct calls to helm.Client for installing/upgrading cluster components + +### 2. Application Deployment (V3 API) +**Location**: `api/internal/managers/app/` +- **Purpose**: Deploy customer applications via Helm charts +- **Features**: Template rendering, install manager, release management + +### 3. Build Tools +**Location**: `cmd/buildtools/` +- **Purpose**: Pull and package charts for airgap bundles +- **Components**: velero.go, seaweedfs.go, registry.go, openebs.go, embeddedclusteroperator.go, adminconsole.go + +### 4. CLI Operations +- **Install Command**: `cmd/installer/cli/install.go` +- **Join Command**: `cmd/installer/cli/join.go` +- **Restore Command**: `cmd/installer/cli/restore.go` +- **Enable HA**: `cmd/installer/cli/enable_ha.go` + +### 5. Operator Upgrade Jobs +**Location**: `operator/pkg/upgrade/upgrade.go`, `operator/pkg/cli/upgrade_job.go` +- **Purpose**: Automated upgrades of cluster components + +### 6. Extensions System +**Location**: `pkg/extensions/` +- **Purpose**: Install and upgrade third-party extensions + +## V2 vs V3 Usage Patterns + +### V3-Specific Features +- **Environment variable**: `ENABLE_V3=1` controls V3 feature activation +- **Usage locations**: + - `cmd/installer/cli/flags.go` - V3 feature flag detection + - `cmd/installer/cli/install.go` - V3 manager experience defaults +- **V3 components**: + - API managers for kubernetes/linux infrastructure + - Application deployment managers + - New manager experience vs legacy installer flow + +### V2/Legacy Pattern +- **Traditional workflow**: Direct CLI-driven installation without API managers +- **Addon installation**: Same Helm client usage for both V2 and V3 +- **Backwards compatibility**: All existing Helm operations work in both modes + +## Critical Dependencies on Helm Go SDK Types + +### Return Value Dependencies +1. **`*release.Release`** - Used by Install() and Upgrade() + - Contains: Name, Namespace, Version, Status, Manifest, Hooks + - **Usage**: Status checking, rollback decisions, manifest extraction + +2. **`*chart.Metadata`** - Used by GetChartMetadata() + - Contains: Name, Version, Dependencies, Annotations + - **Usage**: Version validation, dependency checking + +3. **`[][]byte`** - Used by Render() + - Contains: Rendered YAML manifests as byte slices + - **Usage**: Template processing, manifest application + +### Parameter Dependencies +1. **`*repo.Entry`** - Used by AddRepo() + - Contains: Name, URL, Username, Password, CertFile, KeyFile + - **Usage**: Repository configuration, authentication + +## Special Implementation Considerations + +### Airgap Support +- **Pattern**: `airgapPath` field enables offline chart loading +- **Logic**: Load from `{airgapPath}/{releaseName}-{chartVersion}.tgz` +- **Scope**: All addons and application deployments support airgap +- Current implementation handles airgap via `airgapPath` field in HelmClient +- Charts are loaded from local filesystem in airgap mode + +### Registry Authentication +- **OCI support**: Full OCI registry integration via `registry.Client` +- **Authentication**: Basic auth, registry login support +- **Usage**: Private chart repositories, enterprise scenarios +- Uses registry.Client for OCI authentication +- Supports basic auth via `RegistryAuth()` method +- Critical for private registry scenarios + +### Kubernetes Version Compatibility +- **K0s integration**: `kversion` field for template rendering compatibility +- **Template context**: Correct API versions based on cluster version +- K0s version awareness via `kversion` field +- Used for proper template rendering with correct API versions + +### Error Handling & Retry Logic +- **Retry pattern**: `PullByRefWithRetries(ctx, ref, version, 3)` +- **Error wrapping**: Comprehensive error context throughout +- **Debug logging**: Configurable debug output via `LogFn` +- Retry logic for chart pulls (`PullByRefWithRetries`) +- Detailed error wrapping throughout +- Debug logging via customizable LogFn + +## Test Infrastructure Analysis + +### Mock Usage (32 test files) +- **Primary mock**: `pkg/helm/mock_client.go` (94 lines) +- **Test pattern**: `testify/mock` based mocking +- **Critical mocked operations**: + - Install/Upgrade returning mock `*release.Release` + - Render returning mock `[][]byte` manifests + - GetChartMetadata returning mock `*chart.Metadata` + +### Integration Tests +- **Utility**: `tests/integration/util/helm.go` - HelmClient factory for tests +- **Addon integration tests**: 8 files testing real Helm operations +- **Dryrun tests**: 5 files using mocked clients + +## Architecture Insights + +### Interface Stability Requirements +- **13 method signatures** must remain unchanged for 70+ consuming files +- **3 critical return types** (`*release.Release`, `*chart.Metadata`, `[][]byte`) must be preserved +- **Factory pattern** with `SetClientFactory` enables testing and dependency injection +- Must maintain exact same Client interface +- 70+ files depend on this interface +- Breaking changes would cascade throughout codebase + +### Component Dependencies +``` +CLI Commands → Helm Interface ← API Managers + ↓ ↓ ↓ + Addons → Helm Client ← Extensions + ↓ ↓ ↓ +Build Tools → SDK Implementation ← Tests +``` + +### Operation Flow Patterns +1. **Installation Flow**: NewClient → AddRepo → Pull → Install → Close +2. **Upgrade Flow**: NewClient → ReleaseExists → Pull → Upgrade → Close +3. **Template Flow**: NewClient → Pull → Render → Close +4. **Metadata Flow**: NewClient → Pull → GetChartMetadata → Close + +## Interface Consumers + +### Direct Consumers (via helm.NewClient) +1. CLI commands (install, join, restore, enable_ha) +2. Operator upgrade jobs +3. Integration test utilities +4. Build tools + +### Indirect Consumers (via dependency injection) +1. Addons package (receives helm.Client) +2. Extensions package +3. App managers +4. Infrastructure managers + +## Code References + +### Core Files (Migration Critical) +- `pkg/helm/client.go:1-613` - Complete SDK implementation to replace +- `pkg/helm/interface.go:15-29` - Client interface definition (must preserve) +- `pkg/helm/mock_client.go:1-94` - Mock implementation to update + +### High-Impact Usage Sites +- `pkg/addons/*/install.go` - All addon installation logic (30 files) +- `pkg/extensions/util.go:41-89` - Extension install/upgrade/uninstall +- `api/internal/managers/app/install/install.go` - V3 application deployment +- `cmd/installer/cli/install.go:200+` - CLI installation workflow + +### Test Coverage +- `tests/dryrun/*_test.go` - 5 files with extensive mock usage +- `pkg/addons/*/integration/*_test.go` - 8 files with real Helm operations +- `api/integration/*/install/*_test.go` - 4 files testing install managers + +## Migration Complexity Assessment + +### Binary Management Challenges +1. **Distribution**: How to package/ship helm binary +2. **Versioning**: Ensure consistent helm version +3. **Platform Support**: Linux/Darwin compatibility +4. **Airgap**: Binary must be available offline + +### Operation Translation Complexity +1. **Simple Operations**: Pull, Push, AddRepo (straightforward CLI mapping) +2. **Complex Operations**: Render (requires --dry-run with parsing) +3. **State Operations**: ReleaseExists (requires history parsing) +4. **Value Handling**: Complex value merging and YAML processing + +### Testing Impact +- All existing mocks would need updating +- Integration tests need binary availability +- Build process changes for binary inclusion + +### Performance Considerations +- Process spawning overhead for each operation +- Increased memory usage (separate process) +- Potential for zombie processes +- File descriptor limits with concurrent operations + +## Affected Workflows + +### Critical Paths +1. **Initial Cluster Installation** + - All addon installations + - Registry setup for airgap + - Admin console deployment + +2. **Cluster Upgrades** + - Operator-driven upgrades + - Extension updates + - Application updates + +3. **HA Enablement** + - Scaling critical components + - Reconfiguring services + +4. **Disaster Recovery** + - Restore operations + - Reinstalling components + +### Build and Release Process +- Chart packaging for airgap +- Binary inclusion in releases +- Version compatibility matrix + +## Risk Areas + +### High Impact Components +- **Addon installation** (all cluster infrastructure) +- **Application deployment** (customer workloads) +- **Upgrade operations** (cluster stability) + +### Complex Operations +- Template rendering with value merging +- Chart dependency resolution +- Release rollback on failure +- Concurrent operations handling + +### State Management +- Repository cache management +- Temporary file handling +- Release state tracking + +## Migration Scope Estimates + +### Implementation Requirements +- **Core refactor**: `pkg/helm/client.go` (~800 lines replacing 613 existing) +- **New files**: ~650 lines across 3 new files + - `binary_executor.go` (~100 lines) + - `output_parser.go` (~300 lines) + - Test files (~250 lines) + +### Testing Requirements +- **Mock updates**: 32 test files need mock client updates +- **Integration tests**: Verify binary vs SDK output compatibility +- **Regression testing**: All 70 consuming files need validation + +## Open Questions + +1. **Binary distribution**: How to embed and materialize helm binary via materializer? +2. **Version compatibility**: Which helm binary version to embed for maximum compatibility? +3. **Performance impact**: Process spawning overhead vs in-memory SDK operations? +4. **Error translation**: Mapping CLI error messages to structured error types? +5. **Concurrent operations**: File locking and process management for parallel operations? + +## Recommendations for Migration + +### Critical Success Factors +1. Perfect interface compatibility +2. Comprehensive error handling +3. Binary distribution strategy +4. Rollback capability +5. Performance benchmarking +6. Extended testing period + +### Risk Mitigation +1. Comprehensive testing of all 70 consumer files +2. Binary availability validation in all environments +3. Error handling compatibility with existing patterns +4. Performance monitoring during migration +5. Rollback plan if critical issues arise + +## Key Dependencies +- helm.sh/helm/v3/pkg/* - Core Helm SDK packages (TO BE REMOVED) +- k8s.io/cli-runtime - Kubernetes client configuration +- sigs.k8s.io/controller-runtime - Controller client +- gopkg.in/yaml.v3 - YAML marshaling +- github.com/replicatedhq/embedded-cluster/pkg/helpers - RunCommand functionality + +## Related Research +- **Migration proposal**: `proposals/helm_binary_migration.md` +- **V3 transition**: `proposals/v3_app_deployment_transition.md` \ No newline at end of file diff --git a/proposals/v3_app_deployment_transition.md b/proposals/v3_app_deployment_transition.md index 1caaaeacfc..61f43e4836 100644 --- a/proposals/v3_app_deployment_transition.md +++ b/proposals/v3_app_deployment_transition.md @@ -12,7 +12,8 @@ | sc-128062 | Support the releaseName field in the HelmChart custom resource | | sc-128364 | Rely on KOTS CLI to process the app's airgap bundle and create the image pull secrets | | sc-128060 | Add missing functionality for the image pull secret template functions | -| sc-128058 | Support helmUpgradeFlags field from the HelmChart custom resource when deploying charts | +| sc-128058 | Use Helm binary instead of the Go SDK to manage charts in V3 installs | +| sc-128450 | Support helmUpgradeFlags field from the HelmChart custom resource when deploying charts | | sc-128057 | Sort charts by the weight field in the HelmChart custom resource | | sc-128056 | Make sure the exclude field from the HelmChart custom resource is respected | | sc-128055 | Make sure the namespace field in the HelmChart custom resource is respected | @@ -257,51 +258,48 @@ func (m *appReleaseManager) ExtractInstallableHelmCharts(...) ([]InstallableHelm --- -#### 2.6 Story sc-128058: Support helmUpgradeFlags field +#### 2.6 Story sc-128058: Use Helm binary instead of the Go SDK -**Purpose:** Apply custom Helm upgrade flags from HelmChart CR during installation. +**Purpose:** In order to facilitate the migration from KOTS with minimal risk and potential regressions, and in addition to other benefits, we should use the Helm binary instead of the Go SDK to manage charts + +**Implementation:** See [helm_binary_migration.md](./helm_binary_migration.md) + +#### 2.7 Story sc-128450: Support helmUpgradeFlags field + +**Purpose:** Apply custom Helm upgrade flags from HelmChart CR during installation **Implementation:** -- Parse helmUpgradeFlags from HelmChart CR and apply to Helm install client options -- IMPORTANT: coordinate with the data team to make sure we cover all the flags our current vendors are using and the way they are using them +- Pass helmUpgradeFlags directly to the helm install command arguments ```go // api/internal/managers/app/install/install.go func (m *appInstallManager) installHelmChart(ctx context.Context, chart InstallableHelmChart) error { opts := helm.InstallOptions{...} - // Apply flags from HelmChart CR + // Pass upgrade flags directly as extra args if len(chart.CR.Spec.HelmUpgradeFlags) > 0 { - err := applyHelmUpgradeFlags(opts, chart.CR.Spec.HelmUpgradeFlags) + opts.ExtraArgs = append(opts.ExtraArgs, chart.CR.Spec.HelmUpgradeFlags...) } return m.hcli.Install(ctx, opts) } -func applyHelmUpgradeFlags(opts *helm.InstallOptions, flags []string) error { - // Create a new flag set to parse the helm flags - flagSet := pflag.NewFlagSet("helm-flags", pflag.ContinueOnError) - - // Define flags that helm install supports: https://github.com/helm/helm/blob/main/pkg/cmd/install.go#L187C6-L235 - force := flagSet.Bool("force", false, "") - replace := flagSet.Bool("replace", false, "") - - // Parse the flags - if err := flagSet.Parse(flags); err != nil { - // Return error if the flags are not valid - return err - } - - // Apply parsed values to options - if *force { - opts.Force = true - } - if *replace { - opts.Replace = true - } - // ... - - return nil +// pkg/helm/binary_client.go +func (c *BinaryHelmClient) Install(ctx context.Context, opts InstallOptions) (*release.Release, error) { + args := []string{"install", opts.ReleaseName} + + // ... existing code ... + + // Pass extra args + if len(opts.ExtraArgs) > 0 { + args = append(args, opts.ExtraArgs...) + } + + // Execute helm command + stdout, stderr, err := c.executor.ExecuteCommand(ctx, nil, c.helmPath, args...) + + // Parse release from JSON output + return &release, nil } ``` diff --git a/tests/integration/util/helm.go b/tests/integration/util/helm.go index d6b8a06c30..18d8f8b2ee 100644 --- a/tests/integration/util/helm.go +++ b/tests/integration/util/helm.go @@ -5,10 +5,14 @@ import ( "testing" "github.com/replicatedhq/embedded-cluster/pkg/helm" + helmcli "helm.sh/helm/v3/pkg/cli" ) func HelmClient(t *testing.T, kubeconfig string) helm.Client { - hcli, err := helm.NewClient(helm.HelmOptions{KubeConfig: kubeconfig}) + envSettings := helmcli.New() + envSettings.KubeConfig = kubeconfig + + hcli, err := helm.NewClient(helm.HelmOptions{KubernetesEnvSettings: envSettings}) if err != nil { t.Fatalf("failed to create helm client: %s", err) } diff --git a/versions.mk b/versions.mk index db0d7597f3..e139748891 100644 --- a/versions.mk +++ b/versions.mk @@ -25,6 +25,9 @@ K0S_VERSION = $(K0S_VERSION_1_$(K0S_MINOR_VERSION)) # Format: K0S_BINARY_SOURCE_OVERRIDE_ # Example: K0S_BINARY_SOURCE_OVERRIDE_32 = https://github.com/k0sproject/k0s/releases/download/v1.32.7+k0s.0/k0s-v1.32.7+k0s.0-amd64 +# Helm Version +HELM_VERSION = v3.18.6 + # Troubleshoot Version TROUBLESHOOT_VERSION = v0.121.3 From 1f5e68fbd82df11e834d44661faaa6a06c7ec034 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Tue, 2 Sep 2025 11:14:33 -0700 Subject: [PATCH 02/34] pass version to helm show command --- cmd/buildtools/utils.go | 4 ++-- pkg/helm/client.go | 7 +++++-- pkg/helm/client_test.go | 6 ++++-- pkg/helm/images.go | 12 ------------ pkg/helm/interface.go | 2 +- pkg/helm/mock_client.go | 4 ++-- 6 files changed, 14 insertions(+), 21 deletions(-) diff --git a/cmd/buildtools/utils.go b/cmd/buildtools/utils.go index 55761c28f3..65e4d00726 100644 --- a/cmd/buildtools/utils.go +++ b/cmd/buildtools/utils.go @@ -468,7 +468,7 @@ func MirrorChart(ctx context.Context, hcli helm.Client, repo *repo.Entry, name, logrus.Infof("downloaded %s chart: %s", name, chpath) defer os.Remove(chpath) - srcMeta, err := hcli.GetChartMetadata(ctx, chpath) + srcMeta, err := hcli.GetChartMetadata(ctx, chpath, ver) if err != nil { return fmt.Errorf("get source chart metadata: %w", err) } @@ -487,7 +487,7 @@ func MirrorChart(ctx context.Context, hcli helm.Client, repo *repo.Entry, name, dst := fmt.Sprintf("oci://%s", os.Getenv("CHARTS_DESTINATION")) chartURL := fmt.Sprintf("%s/%s", dst, name) logrus.Infof("verifying if destination tag already exists") - dstMeta, err := helm.GetChartMetadata(ctx, hcli, chartURL, ver) + dstMeta, err := hcli.GetChartMetadata(ctx, chartURL, ver) if err != nil && !strings.HasSuffix(err.Error(), "not found") { return fmt.Errorf("verify tag exists: %w", err) } else if err == nil { diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 8e98ef195c..e2d9c9025f 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -224,7 +224,7 @@ func (h *HelmClient) PullByRef(ctx context.Context, ref string, version string) } // Get chart metadata to determine the actual chart name and construct filename - metadata, err := h.GetChartMetadata(ctx, ref) + metadata, err := h.GetChartMetadata(ctx, ref, version) if err != nil { return "", fmt.Errorf("get chart metadata: %w", err) } @@ -259,9 +259,12 @@ func (h *HelmClient) Push(ctx context.Context, path, dst string) error { return nil } -func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string) (*chart.Metadata, error) { +func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version string) (*chart.Metadata, error) { // Use helm show chart to get chart metadata args := []string{"show", "chart", ref} + if version != "" { + args = append(args, "--version", version) + } stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) if err != nil { diff --git a/pkg/helm/client_test.go b/pkg/helm/client_test.go index b7c0930309..b487b37eb5 100644 --- a/pkg/helm/client_test.go +++ b/pkg/helm/client_test.go @@ -308,6 +308,7 @@ func TestHelmClient_GetChartMetadata(t *testing.T) { name string setupMock func(*MockBinaryExecutor) chartPath string + version string wantErr bool }{ { @@ -316,7 +317,7 @@ func TestHelmClient_GetChartMetadata(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env - []string{"show", "chart", "/path/to/chart"}, + []string{"show", "chart", "/path/to/chart", "--version", "1.0.0"}, ).Return(`apiVersion: v2 name: test-chart description: A test chart @@ -325,6 +326,7 @@ version: 1.0.0 appVersion: "1.0.0"`, "", nil) }, chartPath: "/path/to/chart", + version: "1.0.0", wantErr: false, }, } @@ -339,7 +341,7 @@ appVersion: "1.0.0"`, "", nil) executor: mockExec, } - metadata, err := client.GetChartMetadata(t.Context(), tt.chartPath) + metadata, err := client.GetChartMetadata(t.Context(), tt.chartPath, tt.version) if tt.wantErr { assert.Error(t, err) diff --git a/pkg/helm/images.go b/pkg/helm/images.go index f4f1ca48e9..816de548c1 100644 --- a/pkg/helm/images.go +++ b/pkg/helm/images.go @@ -3,14 +3,12 @@ package helm import ( "context" "fmt" - "os" "slices" "sort" "strings" "github.com/distribution/reference" "github.com/replicatedhq/embedded-cluster/pkg/helpers" - "helm.sh/helm/v3/pkg/chart" k8syaml "sigs.k8s.io/yaml" ) @@ -68,16 +66,6 @@ func ExtractImagesFromChart(hcli Client, ref string, version string, values map[ return images, nil } -func GetChartMetadata(ctx context.Context, hcli Client, ref string, version string) (*chart.Metadata, error) { - chartPath, err := hcli.PullByRef(ctx, ref, version) - if err != nil { - return nil, fmt.Errorf("pull: %w", err) - } - defer os.RemoveAll(chartPath) - - return hcli.GetChartMetadata(ctx, chartPath) -} - func extractImagesFromK8sManifest(resource []byte) ([]string, error) { images := []string{} diff --git a/pkg/helm/interface.go b/pkg/helm/interface.go index 4a2d1d63c2..e1ce8e85d8 100644 --- a/pkg/helm/interface.go +++ b/pkg/helm/interface.go @@ -19,7 +19,7 @@ type Client interface { PullByRef(ctx context.Context, ref string, version string) (string, error) RegistryAuth(ctx context.Context, server, user, pass string) error Push(ctx context.Context, path, dst string) error - GetChartMetadata(ctx context.Context, chartPath string) (*chart.Metadata, error) + GetChartMetadata(ctx context.Context, chartPath string, version string) (*chart.Metadata, error) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) Install(ctx context.Context, opts InstallOptions) (string, error) Upgrade(ctx context.Context, opts UpgradeOptions) (string, error) diff --git a/pkg/helm/mock_client.go b/pkg/helm/mock_client.go index 986d6d6f28..c9d907f705 100644 --- a/pkg/helm/mock_client.go +++ b/pkg/helm/mock_client.go @@ -49,8 +49,8 @@ func (m *MockClient) Push(ctx context.Context, path, dst string) error { return args.Error(0) } -func (m *MockClient) GetChartMetadata(ctx context.Context, chartPath string) (*chart.Metadata, error) { - args := m.Called(ctx, chartPath) +func (m *MockClient) GetChartMetadata(ctx context.Context, chartPath string, version string) (*chart.Metadata, error) { + args := m.Called(ctx, chartPath, version) if args.Get(0) == nil { return nil, args.Error(1) } From 660e3177c968897ffa62a1618df1bc919922f887 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Tue, 2 Sep 2025 13:24:42 -0700 Subject: [PATCH 03/34] add helm to operator image --- dev/dockerfiles/operator/Dockerfile.local | 2 +- dev/dockerfiles/operator/Dockerfile.ttlsh | 3 +++ operator/deploy/apko.tmpl.yaml | 1 + operator/pkg/cli/upgrade_job.go | 1 + pkg/helm/client.go | 23 +++++++++++++++++------ pkg/helm/client_test.go | 4 ++-- 6 files changed, 25 insertions(+), 9 deletions(-) diff --git a/dev/dockerfiles/operator/Dockerfile.local b/dev/dockerfiles/operator/Dockerfile.local index 077020a941..1f4289b4bb 100644 --- a/dev/dockerfiles/operator/Dockerfile.local +++ b/dev/dockerfiles/operator/Dockerfile.local @@ -1,5 +1,5 @@ FROM golang:1.24.6-alpine AS build -RUN apk add --no-cache ca-certificates curl git make bash +RUN apk add --no-cache ca-certificates curl git make bash helm WORKDIR /replicatedhq/embedded-cluster/operator diff --git a/dev/dockerfiles/operator/Dockerfile.ttlsh b/dev/dockerfiles/operator/Dockerfile.ttlsh index b02d60e0d1..616702966c 100644 --- a/dev/dockerfiles/operator/Dockerfile.ttlsh +++ b/dev/dockerfiles/operator/Dockerfile.ttlsh @@ -25,12 +25,15 @@ ENV K0S_VERSION=${K0S_VERSION} ENV GOCACHE=/root/.cache/go-build RUN --mount=type=cache,target="/root/.cache/go-build" make -C operator build +RUN curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=build /app/operator/bin/manager /manager +COPY --from=build /usr/local/bin/helm /usr/local/bin/helm RUN groupadd -r manager && useradd -r -u 1000 -g manager manager USER 1000 diff --git a/operator/deploy/apko.tmpl.yaml b/operator/deploy/apko.tmpl.yaml index d36d38ba0e..86b331045f 100644 --- a/operator/deploy/apko.tmpl.yaml +++ b/operator/deploy/apko.tmpl.yaml @@ -8,6 +8,7 @@ contents: packages: - ec-operator # This is expected to be built locally by `melange`. - ca-certificates-bundle + - helm accounts: groups: diff --git a/operator/pkg/cli/upgrade_job.go b/operator/pkg/cli/upgrade_job.go index c4f2f52d6d..021d1a44b2 100644 --- a/operator/pkg/cli/upgrade_job.go +++ b/operator/pkg/cli/upgrade_job.go @@ -60,6 +60,7 @@ func UpgradeJobCmd() *cobra.Command { } hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH K8sVersion: versions.K0sVersion, AirgapPath: airgapChartsPath, LogFn: func(format string, v ...interface{}) { diff --git a/pkg/helm/client.go b/pkg/helm/client.go index e2d9c9025f..d00fbba8bd 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -28,12 +28,9 @@ func newClient(opts HelmOptions) (*HelmClient, error) { return nil, err } - helmPath := opts.HelmPath - if helmPath == "" { - helmPath, err = goods.Binary("helm") - if err != nil { - return nil, fmt.Errorf("get helm binary: %w", err) - } + helmPath, err := getHelmPath(opts) + if err != nil { + return nil, fmt.Errorf("get helm path: %w", err) } var kversion *semver.Version @@ -56,6 +53,20 @@ func newClient(opts HelmOptions) (*HelmClient, error) { }, nil } +func getHelmPath(opts HelmOptions) (string, error) { + if opts.HelmPath != "" { + return opts.HelmPath, nil + } + if hp := os.Getenv("HELM_BINARY_PATH"); hp != "" { + return hp, nil + } + hp, err := goods.Binary("helm") + if err != nil { + return "", fmt.Errorf("get embedded helm binary: %w", err) + } + return hp, nil +} + type HelmOptions struct { HelmPath string KubernetesEnvSettings *helmcli.EnvSettings diff --git a/pkg/helm/client_test.go b/pkg/helm/client_test.go index b487b37eb5..cc8b6bbc12 100644 --- a/pkg/helm/client_test.go +++ b/pkg/helm/client_test.go @@ -61,7 +61,7 @@ func TestHelmClient_PullByRef(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env - []string{"show", "chart", "myrepo/mychart"}, + []string{"show", "chart", "myrepo/mychart", "--version", "1.2.3"}, ).Return(`apiVersion: v2 name: mychart description: A test chart from repo @@ -100,7 +100,7 @@ appVersion: "1.0.0"`, "", nil) m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env - []string{"show", "chart", "oci://registry.example.com/charts/nginx"}, + []string{"show", "chart", "oci://registry.example.com/charts/nginx", "--version", "2.1.0"}, ).Return(`apiVersion: v2 name: nginx description: A nginx chart from OCI registry From 75554c51cb074ab1d579b732c658aa9140be5830 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Tue, 2 Sep 2025 13:56:25 -0700 Subject: [PATCH 04/34] fix tests --- .github/workflows/ci.yaml | 4 + .../kubernetes/install/controller_test.go | 6 ++ .../managers/app/release/template_test.go | 4 + .../managers/kubernetes/infra/manager_test.go | 83 +------------------ pkg/helm/client.go | 2 + tests/integration/util/helm.go | 5 +- 6 files changed, 22 insertions(+), 82 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1604f61dc4..f770bb599a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -157,6 +157,8 @@ jobs: int-tests-kind: name: Integration tests (kind) runs-on: ubuntu-latest + needs: + - should-run-int-tests-kind if: needs.should-run-int-tests-kind.outputs.run == 'true' steps: - name: Checkout @@ -178,6 +180,8 @@ jobs: int-tests-kind-ha-registry: name: Integration tests (kind) HA registry runs-on: ubuntu-latest + needs: + - should-run-int-tests-kind if: needs.should-run-int-tests-kind.outputs.run == 'true' steps: - name: Checkout diff --git a/api/controllers/kubernetes/install/controller_test.go b/api/controllers/kubernetes/install/controller_test.go index 39b7b86449..727271436d 100644 --- a/api/controllers/kubernetes/install/controller_test.go +++ b/api/controllers/kubernetes/install/controller_test.go @@ -90,6 +90,8 @@ func TestGetInstallationConfig(t *testing.T) { }, } + t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ki := kubernetesinstallation.New(nil) @@ -201,6 +203,8 @@ func TestConfigureInstallation(t *testing.T) { }, } + t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockInstallation := &kubernetesinstallation.MockInstallation{} @@ -276,6 +280,8 @@ func TestGetInstallationStatus(t *testing.T) { }, } + t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockManager := &installation.MockInstallationManager{} diff --git a/api/internal/managers/app/release/template_test.go b/api/internal/managers/app/release/template_test.go index d5a5f7269b..17d84d85f1 100644 --- a/api/internal/managers/app/release/template_test.go +++ b/api/internal/managers/app/release/template_test.go @@ -322,6 +322,8 @@ spec: }, } + t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create release data @@ -1122,6 +1124,8 @@ spec: }, } + t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a basic config for the template engine diff --git a/api/internal/managers/kubernetes/infra/manager_test.go b/api/internal/managers/kubernetes/infra/manager_test.go index 831986175f..6d8ca9ef6e 100644 --- a/api/internal/managers/kubernetes/infra/manager_test.go +++ b/api/internal/managers/kubernetes/infra/manager_test.go @@ -3,13 +3,11 @@ package infra import ( "testing" - "github.com/replicatedhq/embedded-cluster/api/internal/clients" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" helmcli "helm.sh/helm/v3/pkg/cli" metadatafake "k8s.io/client-go/metadata/fake" - "k8s.io/client-go/rest" "k8s.io/kubectl/pkg/scheme" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) @@ -68,6 +66,8 @@ func TestNewInfraManager_ClientCreation(t *testing.T) { }, } + t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Build options @@ -102,82 +102,3 @@ func TestNewInfraManager_ClientCreation(t *testing.T) { }) } } - -func TestNewInfraManager_ToRESTConfigError(t *testing.T) { - tests := []struct { - name string - withKubeClient bool - withMetadataClient bool - withHelmClient bool - expectedError string - }{ - { - name: "kube client creation fails", - withMetadataClient: true, - expectedError: "create kube client:", - }, - { - name: "metadata client creation fails", - withKubeClient: true, - expectedError: "create metadata client:", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mock RESTClientGetter that returns error - mockRestClientGetter := &clients.MockRESTClientGetter{} - mockRestClientGetter.On("ToRESTConfig").Return((*rest.Config)(nil), assert.AnError) - - // Build options - opts := []InfraManagerOption{ - WithKubernetesEnvSettings(helmcli.New()), - } - - // Add pre-created clients if specified - if tt.withKubeClient { - opts = append(opts, WithKubeClient(fake.NewFakeClient())) - } - if tt.withMetadataClient { - opts = append(opts, WithMetadataClient(metadatafake.NewSimpleMetadataClient(scheme.Scheme))) - } - opts = append(opts, WithHelmClient(&helm.MockClient{})) - - // Create manager - manager, err := NewInfraManager(opts...) - - require.Error(t, err) - assert.Contains(t, err.Error(), tt.expectedError) - assert.Nil(t, manager) - - // Verify mock expectations - mockRestClientGetter.AssertExpectations(t) - }) - } -} - -func TestNewInfraManager_WithoutRESTClientGetter(t *testing.T) { - // Test that creating manager without RESTClientGetter fails when clients need to be created - manager, err := NewInfraManager() - - require.Error(t, err) - assert.Contains(t, err.Error(), "a valid kube config is required to create a kube client") - assert.Nil(t, manager) -} - -func TestNewInfraManager_WithAllClientsProvided(t *testing.T) { - // Test that when all clients are provided, no RESTClientGetter is needed - opts := []InfraManagerOption{ - WithKubeClient(fake.NewFakeClient()), - WithMetadataClient(metadatafake.NewSimpleMetadataClient(scheme.Scheme)), - WithHelmClient(&helm.MockClient{}), - } - - manager, err := NewInfraManager(opts...) - - require.NoError(t, err) - assert.NotNil(t, manager) - assert.NotNil(t, manager.kcli) - assert.NotNil(t, manager.mcli) - assert.NotNil(t, manager.hcli) -} diff --git a/pkg/helm/client.go b/pkg/helm/client.go index d00fbba8bd..fe3339a773 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -55,9 +55,11 @@ func newClient(opts HelmOptions) (*HelmClient, error) { func getHelmPath(opts HelmOptions) (string, error) { if opts.HelmPath != "" { + // used in operations that do not have the helm binary embedded (i.e. upgrade via operator, etc..) return opts.HelmPath, nil } if hp := os.Getenv("HELM_BINARY_PATH"); hp != "" { + // used in tests so that we don't pass the helm binary path all over the place return hp, nil } hp, err := goods.Binary("helm") diff --git a/tests/integration/util/helm.go b/tests/integration/util/helm.go index 18d8f8b2ee..42aa25d87e 100644 --- a/tests/integration/util/helm.go +++ b/tests/integration/util/helm.go @@ -12,7 +12,10 @@ func HelmClient(t *testing.T, kubeconfig string) helm.Client { envSettings := helmcli.New() envSettings.KubeConfig = kubeconfig - hcli, err := helm.NewClient(helm.HelmOptions{KubernetesEnvSettings: envSettings}) + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + KubernetesEnvSettings: envSettings, + }) if err != nil { t.Fatalf("failed to create helm client: %s", err) } From 2a20b7cb05c89f93992a61f89d2353d7f4b23d12 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Wed, 3 Sep 2025 09:37:34 -0700 Subject: [PATCH 05/34] feedback --- .github/workflows/dependencies.yaml | 7 +++++++ cmd/installer/cli/install.go | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependencies.yaml b/.github/workflows/dependencies.yaml index a574a4f855..9fe9e4ddd8 100644 --- a/.github/workflows/dependencies.yaml +++ b/.github/workflows/dependencies.yaml @@ -45,6 +45,13 @@ jobs: version=$(gh release list --repo axboe/fio --json name,isLatest | jq -r '.[] | select(.isLatest)|.name' | cut -d- -f2) echo "fio version: $version" sed -i "/^FIO_VERSION/c\FIO_VERSION = $version" versions.mk + - name: Helm + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version=$(gh release list --repo helm/helm --json tagName,isLatest | jq -r '.[] | select(.isLatest) | .tagName') + echo "helm version: $version" + sed -i "/^HELM_VERSION/c\HELM_VERSION = $version" versions.mk - name: Create Pull Request uses: peter-evans/create-pull-request@v7 with: diff --git a/cmd/installer/cli/install.go b/cmd/installer/cli/install.go index a9f56710ed..db86bc1ab1 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -54,7 +54,6 @@ import ( helmcli "helm.sh/helm/v3/pkg/cli" "k8s.io/client-go/discovery" "k8s.io/client-go/metadata" - "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -588,7 +587,7 @@ func preRunInstallKubernetes(_ *cobra.Command, flags *InstallCmdFlags, ki kubern } } - restConfig, err := clientcmd.BuildConfigFromFlags("", flags.kubernetesEnvSettings.KubeConfig) + restConfig, err := flags.kubernetesEnvSettings.RESTClientGetter().ToRESTConfig() if err != nil { return fmt.Errorf("failed to discover kubeconfig: %w", err) } From b62ca4dfa84e43e31b5ffac1a71db476b66bd7b1 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Wed, 3 Sep 2025 13:44:02 -0700 Subject: [PATCH 06/34] feedback --- api/api.go | 13 ++ api/clients.go | 88 +++++++++++ api/controllers/app/install/controller.go | 20 +-- api/controllers/app/install/test_suite.go | 13 +- .../kubernetes/install/controller.go | 13 +- .../kubernetes/install/controller_test.go | 40 +++-- api/controllers/linux/install/controller.go | 13 +- .../linux/install/controller_test.go | 13 +- api/handlers.go | 2 + api/integration/app/install/config_test.go | 4 +- api/integration/auth/controller_test.go | 2 + .../kubernetes/install/appconfig_test.go | 7 +- .../kubernetes/install/appinstall_test.go | 36 +++-- .../kubernetes/install/apppreflight_test.go | 16 +- .../kubernetes/install/infra_test.go | 6 +- .../kubernetes/install/installation_test.go | 11 +- .../linux/install/appconfig_test.go | 4 + .../linux/install/appinstall_test.go | 32 +++- .../linux/install/apppreflight_test.go | 14 +- .../linux/install/hostpreflight_test.go | 9 ++ api/integration/linux/install/infra_test.go | 7 + .../linux/install/installation_test.go | 7 + api/integration/util.go | 17 ++- .../handlers/kubernetes/kubernetes.go | 20 ++- api/internal/handlers/kubernetes/util.go | 23 --- api/internal/handlers/linux/linux.go | 9 ++ api/internal/managers/app/install/install.go | 6 +- .../managers/app/install/install_test.go | 7 +- api/internal/managers/app/install/manager.go | 39 ++--- api/internal/managers/app/install/util.go | 19 --- api/internal/managers/app/release/manager.go | 12 +- api/internal/managers/app/release/template.go | 3 - .../managers/app/release/template_test.go | 26 ++-- api/internal/managers/app/release/util.go | 17 --- .../managers/kubernetes/infra/manager.go | 11 +- .../managers/kubernetes/infra/manager_test.go | 38 ++--- .../managers/kubernetes/infra/status_test.go | 3 +- api/internal/managers/linux/infra/install.go | 2 +- api/internal/managers/linux/infra/util.go | 26 +--- cmd/installer/cli/api_test.go | 8 + cmd/installer/cli/enable_ha.go | 1 + cmd/installer/cli/install.go | 1 + cmd/installer/cli/join.go | 1 + cmd/installer/cli/restore.go | 3 + .../integration/hostcabundle_test.go | 3 +- .../integration/kubernetes_test.go | 3 +- .../adminconsole/integration/linux_test.go | 3 +- .../integration/hostcabundle_test.go | 3 +- .../velero/integration/hostcabundle_test.go | 3 +- .../velero/integration/k0ssubdir_test.go | 3 +- pkg/helm/binary_executor.go | 23 ++- pkg/helm/binary_executor_mock.go | 4 +- pkg/helm/binary_executor_test.go | 36 +++-- pkg/helm/client.go | 85 ++++++----- pkg/helm/client_test.go | 18 ++- tests/integration/util/helm.go | 1 + .../installation/shared/ErrorMessage.test.tsx | 137 ++++++++++++++++++ .../installation/shared/ErrorMessage.tsx | 65 +++++++-- .../shared/InstallationProgress.tsx | 6 +- .../installation/shared/LogViewer.test.tsx | 45 ++++++ .../wizard/installation/shared/LogViewer.tsx | 4 +- 61 files changed, 748 insertions(+), 356 deletions(-) create mode 100644 api/clients.go delete mode 100644 api/internal/handlers/kubernetes/util.go create mode 100644 web/src/components/wizard/installation/shared/ErrorMessage.test.tsx diff --git a/api/api.go b/api/api.go index 48d6b3df77..0f0e3d6df8 100644 --- a/api/api.go +++ b/api/api.go @@ -9,6 +9,7 @@ import ( linuxinstall "github.com/replicatedhq/embedded-cluster/api/controllers/linux/install" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/sirupsen/logrus" @@ -37,6 +38,7 @@ import ( type API struct { cfg types.APIConfig + hcli helm.Client logger logrus.FieldLogger metricsReporter metrics.ReporterInterface @@ -93,6 +95,13 @@ func WithMetricsReporter(metricsReporter metrics.ReporterInterface) Option { } } +// WithHelmClient configures the helm client for the API. +func WithHelmClient(hcli helm.Client) Option { + return func(a *API) { + a.hcli = hcli + } +} + // New creates a new API instance. func New(cfg types.APIConfig, opts ...Option) (*API, error) { if cfg.InstallTarget == "" { @@ -119,6 +128,10 @@ func New(cfg types.APIConfig, opts ...Option) (*API, error) { api.logger = l } + if err := api.initClients(); err != nil { + return nil, fmt.Errorf("init clients: %w", err) + } + if err := api.initHandlers(); err != nil { return nil, fmt.Errorf("init handlers: %w", err) } diff --git a/api/clients.go b/api/clients.go new file mode 100644 index 0000000000..b7ca8bbdf4 --- /dev/null +++ b/api/clients.go @@ -0,0 +1,88 @@ +package api + +import ( + "fmt" + + "github.com/replicatedhq/embedded-cluster/api/internal/clients" + "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/replicatedhq/embedded-cluster/pkg/versions" +) + +func (a *API) initClients() error { + if a.hcli == nil { + if err := a.initHelmClient(); err != nil { + return fmt.Errorf("init helm client: %w", err) + } + } + return nil +} + +// initHelmClient initializes the Helm client based on the installation target +func (a *API) initHelmClient() error { + switch a.cfg.InstallTarget { + case types.InstallTargetLinux: + return a.initLinuxHelmClient() + case types.InstallTargetKubernetes: + return a.initKubernetesHelmClient() + default: + return fmt.Errorf("unsupported install target: %s", a.cfg.InstallTarget) + } +} + +// initLinuxHelmClient initializes the Helm client for Linux installations +func (a *API) initLinuxHelmClient() error { + airgapPath := "" + if a.cfg.AirgapBundle != "" { + airgapPath = a.cfg.RuntimeConfig.EmbeddedClusterChartsSubDir() + } + + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: a.cfg.RuntimeConfig.PathToEmbeddedClusterBinary("helm"), + KubernetesEnvSettings: a.cfg.RuntimeConfig.GetKubernetesEnvSettings(), + K8sVersion: versions.K0sVersion, + AirgapPath: airgapPath, + }) + if err != nil { + return fmt.Errorf("create linux helm client: %w", err) + } + + a.hcli = hcli + return nil +} + +// initKubernetesHelmClient initializes the Helm client for Kubernetes installations +func (a *API) initKubernetesHelmClient() error { + // get the kubernetes version + kcli, err := clients.NewDiscoveryClient(clients.KubeClientOptions{ + RESTClientGetter: a.cfg.Installation.GetKubernetesEnvSettings().RESTClientGetter(), + }) + if err != nil { + return fmt.Errorf("create discovery client: %w", err) + } + k8sVersion, err := kcli.ServerVersion() + if err != nil { + return fmt.Errorf("get server version: %w", err) + } + + // get the helm binary path + helmPath, err := a.cfg.Installation.PathToEmbeddedBinary("helm") + if err != nil { + return fmt.Errorf("get helm path: %w", err) + } + + // create the helm client + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: helmPath, + KubernetesEnvSettings: a.cfg.Installation.GetKubernetesEnvSettings(), + // TODO: how can we support airgap? + AirgapPath: "", + K8sVersion: k8sVersion.String(), + }) + if err != nil { + return fmt.Errorf("create kubernetes helm client: %w", err) + } + + a.hcli = hcli + return nil +} diff --git a/api/controllers/app/install/controller.go b/api/controllers/app/install/controller.go index 3550a83ab7..ed8f38a6a1 100644 --- a/api/controllers/app/install/controller.go +++ b/api/controllers/app/install/controller.go @@ -13,10 +13,10 @@ import ( "github.com/replicatedhq/embedded-cluster/api/internal/store" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" - helmcli "helm.sh/helm/v3/pkg/cli" kyaml "sigs.k8s.io/yaml" ) @@ -48,8 +48,7 @@ type InstallController struct { clusterID string airgapBundle string privateCACertConfigMapName string - k8sVersion string - kubernetesEnvSettings *helmcli.EnvSettings + hcli helm.Client } type InstallControllerOption func(*InstallController) @@ -132,15 +131,9 @@ func WithPrivateCACertConfigMapName(configMapName string) InstallControllerOptio } } -func WithK8sVersion(k8sVersion string) InstallControllerOption { +func WithHelmClient(hcli helm.Client) InstallControllerOption { return func(c *InstallController) { - c.k8sVersion = k8sVersion - } -} - -func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) InstallControllerOption { - return func(c *InstallController) { - c.kubernetesEnvSettings = envSettings + c.hcli = hcli } } @@ -205,7 +198,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appreleasemanager.WithReleaseData(controller.releaseData), appreleasemanager.WithLicense(license), appreleasemanager.WithPrivateCACertConfigMapName(controller.privateCACertConfigMapName), - appreleasemanager.WithK8sVersion(controller.k8sVersion), + appreleasemanager.WithHelmClient(controller.hcli), ) if err != nil { return nil, fmt.Errorf("create app release manager: %w", err) @@ -221,8 +214,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appinstallmanager.WithClusterID(controller.clusterID), appinstallmanager.WithAirgapBundle(controller.airgapBundle), appinstallmanager.WithAppInstallStore(controller.store.AppInstallStore()), - appinstallmanager.WithK8sVersion(controller.k8sVersion), - appinstallmanager.WithKubernetesEnvSettings(controller.kubernetesEnvSettings), + appinstallmanager.WithHelmClient(controller.hcli), ) if err != nil { return nil, fmt.Errorf("create app install manager: %w", err) diff --git a/api/controllers/app/install/test_suite.go b/api/controllers/app/install/test_suite.go index dbeffca75f..df578001d7 100644 --- a/api/controllers/app/install/test_suite.go +++ b/api/controllers/app/install/test_suite.go @@ -14,6 +14,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/internal/store" "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" @@ -132,6 +133,7 @@ func (s *AppInstallControllerTestSuite) TestPatchAppConfigValues() { appPreflightManager := &apppreflightmanager.MockAppPreflightManager{} appReleaseManager := &appreleasemanager.MockAppReleaseManager{} appInstallManager := &appinstallmanager.MockAppInstallManager{} + mockHelmClient := &helm.MockClient{} sm := s.CreateStateMachine(tt.currentState) controller, err := NewInstallController( @@ -142,7 +144,7 @@ func (s *AppInstallControllerTestSuite) TestPatchAppConfigValues() { WithAppInstallManager(appInstallManager), WithStore(&store.MockStore{}), WithReleaseData(&release.ReleaseData{}), - WithK8sVersion("v1.33.0"), + WithHelmClient(mockHelmClient), ) require.NoError(t, err, "failed to create install controller") @@ -402,6 +404,7 @@ func (s *AppInstallControllerTestSuite) TestRunAppPreflights() { appConfigManager := &appconfig.MockAppConfigManager{} appPreflightManager := &apppreflightmanager.MockAppPreflightManager{} appReleaseManager := &appreleasemanager.MockAppReleaseManager{} + mockHelmClient := &helm.MockClient{} sm := s.CreateStateMachine(tt.currentState) controller, err := NewInstallController( WithStateMachine(sm), @@ -410,7 +413,7 @@ func (s *AppInstallControllerTestSuite) TestRunAppPreflights() { WithAppReleaseManager(appReleaseManager), WithStore(&store.MockStore{}), WithReleaseData(&release.ReleaseData{}), - WithK8sVersion("v1.33.0"), + WithHelmClient(mockHelmClient), ) require.NoError(t, err, "failed to create install controller") @@ -473,6 +476,7 @@ func (s *AppInstallControllerTestSuite) TestGetAppInstallStatus() { appPreflightManager := &apppreflightmanager.MockAppPreflightManager{} appReleaseManager := &appreleasemanager.MockAppReleaseManager{} appInstallManager := &appinstallmanager.MockAppInstallManager{} + mockHelmClient := &helm.MockClient{} sm := s.CreateStateMachine(states.StateNew) controller, err := NewInstallController( @@ -483,7 +487,7 @@ func (s *AppInstallControllerTestSuite) TestGetAppInstallStatus() { WithAppInstallManager(appInstallManager), WithStore(&store.MockStore{}), WithReleaseData(&release.ReleaseData{}), - WithK8sVersion("v1.33.0"), + WithHelmClient(mockHelmClient), ) require.NoError(t, err, "failed to create install controller") @@ -685,6 +689,7 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { appPreflightManager := &apppreflightmanager.MockAppPreflightManager{} appReleaseManager := &appreleasemanager.MockAppReleaseManager{} appInstallManager := &appinstallmanager.MockAppInstallManager{} + mockHelmClient := &helm.MockClient{} sm := s.CreateStateMachine(tt.currentState) controller, err := NewInstallController( @@ -695,7 +700,7 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { WithAppInstallManager(appInstallManager), WithStore(&store.MockStore{}), WithReleaseData(&release.ReleaseData{}), - WithK8sVersion("v1.33.0"), + WithHelmClient(mockHelmClient), ) require.NoError(t, err, "failed to create install controller") diff --git a/api/controllers/kubernetes/install/controller.go b/api/controllers/kubernetes/install/controller.go index 90dd8f9f5a..430616f4c6 100644 --- a/api/controllers/kubernetes/install/controller.go +++ b/api/controllers/kubernetes/install/controller.go @@ -14,6 +14,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/sirupsen/logrus" @@ -36,8 +37,8 @@ type InstallController struct { installationManager installation.InstallationManager infraManager infra.InfraManager metricsReporter metrics.ReporterInterface - k8sVersion string kubernetesEnvSettings *helmcli.EnvSettings + hcli helm.Client releaseData *release.ReleaseData password string tlsConfig types.TLSConfig @@ -73,9 +74,9 @@ func WithMetricsReporter(metricsReporter metrics.ReporterInterface) InstallContr } } -func WithK8sVersion(k8sVersion string) InstallControllerOption { +func WithHelmClient(hcli helm.Client) InstallControllerOption { return func(c *InstallController) { - c.k8sVersion = k8sVersion + c.hcli = hcli } } @@ -197,9 +198,8 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appcontroller.WithReleaseData(controller.releaseData), appcontroller.WithConfigValues(controller.configValues), appcontroller.WithAirgapBundle(controller.airgapBundle), - appcontroller.WithPrivateCACertConfigMapName(""), // Private CA ConfigMap functionality not yet implemented for Kubernetes installations - appcontroller.WithK8sVersion(controller.k8sVersion), // Used to determine the kubernetes version for the helm client - appcontroller.WithKubernetesEnvSettings(controller.kubernetesEnvSettings), + appcontroller.WithPrivateCACertConfigMapName(""), // Private CA ConfigMap functionality not yet implemented for Kubernetes installations + appcontroller.WithHelmClient(controller.hcli), ) if err != nil { return nil, fmt.Errorf("create app install controller: %w", err) @@ -218,6 +218,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, infra.WithAirgapBundle(controller.airgapBundle), infra.WithReleaseData(controller.releaseData), infra.WithEndUserConfig(controller.endUserConfig), + infra.WithHelmClient(controller.hcli), ) if err != nil { return nil, fmt.Errorf("create infra manager: %w", err) diff --git a/api/controllers/kubernetes/install/controller_test.go b/api/controllers/kubernetes/install/controller_test.go index 727271436d..67e80a4457 100644 --- a/api/controllers/kubernetes/install/controller_test.go +++ b/api/controllers/kubernetes/install/controller_test.go @@ -15,6 +15,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" @@ -90,8 +91,6 @@ func TestGetInstallationConfig(t *testing.T) { }, } - t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ki := kubernetesinstallation.New(nil) @@ -100,11 +99,18 @@ func TestGetInstallationConfig(t *testing.T) { mockManager := &installation.MockInstallationManager{} tt.setupMock(mockManager) + // Create real helm client + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", + }) + require.NoError(t, err) + controller, err := NewInstallController( WithInstallation(ki), WithInstallationManager(mockManager), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), - WithK8sVersion("v1.33.0"), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -203,8 +209,6 @@ func TestConfigureInstallation(t *testing.T) { }, } - t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockInstallation := &kubernetesinstallation.MockInstallation{} @@ -217,6 +221,13 @@ func TestConfigureInstallation(t *testing.T) { tt.setupMock(mockManager, mockInstallation, tt.config, mockStore, metricsReporter) + // Create real helm client + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.26.0", + }) + require.NoError(t, err) + controller, err := NewInstallController( WithInstallation(mockInstallation), WithStateMachine(sm), @@ -224,7 +235,7 @@ func TestConfigureInstallation(t *testing.T) { WithStore(mockStore), WithMetricsReporter(metricsReporter), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), - WithK8sVersion("v1.33.0"), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -280,17 +291,22 @@ func TestGetInstallationStatus(t *testing.T) { }, } - t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockManager := &installation.MockInstallationManager{} tt.setupMock(mockManager) + // Create real helm client + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.26.0", + }) + require.NoError(t, err) + controller, err := NewInstallController( WithInstallationManager(mockManager), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), - WithK8sVersion("v1.33.0"), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -417,7 +433,7 @@ func TestSetupInfra(t *testing.T) { appcontroller.WithStore(mockStore), appcontroller.WithReleaseData(getTestReleaseData(&appConfig)), appcontroller.WithAppConfigManager(mockAppConfigManager), - appcontroller.WithK8sVersion("v1.33.0"), + appcontroller.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -430,7 +446,7 @@ func TestSetupInfra(t *testing.T) { WithMetricsReporter(mockMetricsReporter), WithReleaseData(getTestReleaseData(&appConfig)), WithStore(mockStore), - WithK8sVersion("v1.33.0"), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -525,7 +541,7 @@ func TestGetInfra(t *testing.T) { controller, err := NewInstallController( WithInfraManager(mockManager), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), - WithK8sVersion("v1.33.0"), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/controllers/linux/install/controller.go b/api/controllers/linux/install/controller.go index 884dd70405..2f935655f8 100644 --- a/api/controllers/linux/install/controller.go +++ b/api/controllers/linux/install/controller.go @@ -18,10 +18,10 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" "github.com/replicatedhq/embedded-cluster/pkg/addons/adminconsole" "github.com/replicatedhq/embedded-cluster/pkg/airgap" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" - "github.com/replicatedhq/embedded-cluster/pkg/versions" "github.com/sirupsen/logrus" ) @@ -65,6 +65,7 @@ type InstallController struct { clusterID string store store.Store rc runtimeconfig.RuntimeConfig + hcli helm.Client stateMachine statemachine.Interface logger logrus.FieldLogger allowIgnoreHostPreflights bool @@ -206,6 +207,12 @@ func WithStore(store store.Store) InstallControllerOption { } } +func WithHelmClient(hcli helm.Client) InstallControllerOption { + return func(c *InstallController) { + c.hcli = hcli + } +} + func NewInstallController(opts ...InstallControllerOption) (*InstallController, error) { controller := &InstallController{ store: store.NewMemoryStore(), @@ -267,8 +274,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appcontroller.WithClusterID(controller.clusterID), appcontroller.WithAirgapBundle(controller.airgapBundle), appcontroller.WithPrivateCACertConfigMapName(adminconsole.PrivateCASConfigMapName), // Linux installations use the ConfigMap - appcontroller.WithK8sVersion(versions.K0sVersion), // Used to determine the kubernetes version for the helm client - appcontroller.WithKubernetesEnvSettings(controller.rc.GetKubernetesEnvSettings()), + appcontroller.WithHelmClient(controller.hcli), ) if err != nil { return nil, fmt.Errorf("create app install controller: %w", err) @@ -289,6 +295,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, infra.WithReleaseData(controller.releaseData), infra.WithEndUserConfig(controller.endUserConfig), infra.WithClusterID(controller.clusterID), + infra.WithHelmClient(controller.hcli), ) } diff --git a/api/controllers/linux/install/controller_test.go b/api/controllers/linux/install/controller_test.go index 62e5f29a00..4a5b032307 100644 --- a/api/controllers/linux/install/controller_test.go +++ b/api/controllers/linux/install/controller_test.go @@ -17,6 +17,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/internal/store" "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" @@ -153,6 +154,7 @@ func TestGetInstallationConfig(t *testing.T) { WithRuntimeConfig(rc), WithInstallationManager(mockManager), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -423,6 +425,7 @@ func TestConfigureInstallation(t *testing.T) { WithStore(mockStore), WithMetricsReporter(metricsReporter), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -489,6 +492,7 @@ func TestIntegrationComputeCIDRs(t *testing.T) { t.Run(tt.name, func(t *testing.T) { controller, err := NewInstallController( WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -789,6 +793,7 @@ func TestRunHostPreflights(t *testing.T) { WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), WithMetricsReporter(mockReporter), WithStore(mockStore), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -853,6 +858,7 @@ func TestGetHostPreflightStatus(t *testing.T) { controller, err := NewInstallController( WithHostPreflightManager(mockManager), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -905,6 +911,7 @@ func TestGetHostPreflightOutput(t *testing.T) { controller, err := NewInstallController( WithHostPreflightManager(mockManager), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -957,6 +964,7 @@ func TestGetHostPreflightTitles(t *testing.T) { controller, err := NewInstallController( WithHostPreflightManager(mockManager), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -1013,6 +1021,7 @@ func TestGetInstallationStatus(t *testing.T) { controller, err := NewInstallController( WithInstallationManager(mockManager), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -1199,11 +1208,11 @@ func TestSetupInfra(t *testing.T) { appcontroller.WithStateMachine(sm), appcontroller.WithStore(mockStore), appcontroller.WithReleaseData(getTestReleaseData(&appConfig)), - appcontroller.WithK8sVersion("v1.33.0"), appcontroller.WithLicense([]byte("spec:\n licenseID: test-license\n")), appcontroller.WithAppConfigManager(mockAppConfigManager), appcontroller.WithAppPreflightManager(mockAppPreflightManager), appcontroller.WithAppReleaseManager(mockAppReleaseManager), + appcontroller.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -1219,6 +1228,7 @@ func TestSetupInfra(t *testing.T) { WithReleaseData(getTestReleaseData(&appConfig)), WithLicense([]byte("spec:\n licenseID: test-license\n")), WithStore(mockStore), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -1315,6 +1325,7 @@ func TestGetInfra(t *testing.T) { controller, err := NewInstallController( WithInfraManager(mockManager), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/handlers.go b/api/handlers.go index 7149225be1..464dcb74c0 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -57,6 +57,7 @@ func (a *API) initHandlers() error { linuxhandler.WithLogger(a.logger), linuxhandler.WithMetricsReporter(a.metricsReporter), linuxhandler.WithInstallController(a.linuxInstallController), + linuxhandler.WithHelmClient(a.hcli), ) if err != nil { return fmt.Errorf("new linux handler: %w", err) @@ -68,6 +69,7 @@ func (a *API) initHandlers() error { a.cfg, kuberneteshandler.WithLogger(a.logger), kuberneteshandler.WithInstallController(a.kubernetesInstallController), + kuberneteshandler.WithHelmClient(a.hcli), ) if err != nil { return fmt.Errorf("new kubernetes handler: %w", err) diff --git a/api/integration/app/install/config_test.go b/api/integration/app/install/config_test.go index 3acb299d27..0f0c2c46fe 100644 --- a/api/integration/app/install/config_test.go +++ b/api/integration/app/install/config_test.go @@ -18,6 +18,7 @@ import ( states "github.com/replicatedhq/embedded-cluster/api/internal/states/install" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/kotskinds/multitype" @@ -1049,6 +1050,7 @@ func TestAppInstallSuite(t *testing.T) { linuxinstall.WithReleaseData(rd), linuxinstall.WithLicense([]byte("spec:\n licenseID: test-license\n")), linuxinstall.WithConfigValues(configValues), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller @@ -1069,8 +1071,8 @@ func TestAppInstallSuite(t *testing.T) { kubernetesinstall.WithReleaseData(rd), kubernetesinstall.WithLicense([]byte("spec:\n licenseID: test-license\n")), kubernetesinstall.WithConfigValues(configValues), - kubernetesinstall.WithK8sVersion("v1.33.0"), kubernetesinstall.WithKubernetesEnvSettings(helmcli.New()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller diff --git a/api/integration/auth/controller_test.go b/api/integration/auth/controller_test.go index 490ab440b5..a4018765d6 100644 --- a/api/integration/auth/controller_test.go +++ b/api/integration/auth/controller_test.go @@ -17,6 +17,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -32,6 +33,7 @@ func TestAuthLoginAndTokenValidation(t *testing.T) { linuxinstallation.WithNetUtils(&utils.MockNetUtils{}), )), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/kubernetes/install/appconfig_test.go b/api/integration/kubernetes/install/appconfig_test.go index 839c27319f..f74382858f 100644 --- a/api/integration/kubernetes/install/appconfig_test.go +++ b/api/integration/kubernetes/install/appconfig_test.go @@ -15,6 +15,7 @@ import ( states "github.com/replicatedhq/embedded-cluster/api/internal/states/install" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/kotskinds/multitype" @@ -65,7 +66,7 @@ func TestInstallController_PatchAppConfigValuesWithAPIClient(t *testing.T) { kubernetesinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -117,7 +118,7 @@ func TestInstallController_PatchAppConfigValuesWithAPIClient(t *testing.T) { kubernetesinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -221,7 +222,7 @@ func TestInstallController_GetAppConfigValuesWithAPIClient(t *testing.T) { kubernetesinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/kubernetes/install/appinstall_test.go b/api/integration/kubernetes/install/appinstall_test.go index 2612776624..b6d1d69ca1 100644 --- a/api/integration/kubernetes/install/appinstall_test.go +++ b/api/integration/kubernetes/install/appinstall_test.go @@ -25,6 +25,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" @@ -69,7 +70,7 @@ func TestGetAppInstallStatus(t *testing.T) { appinstallmanager.WithAppInstallStore( appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(appInstallStatus)), ), - appinstallmanager.WithK8sVersion("v1.33.0"), + appinstallmanager.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -82,7 +83,7 @@ func TestGetAppInstallStatus(t *testing.T) { appinstall.WithStateMachine(kubernetesinstall.NewStateMachine()), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -100,7 +101,7 @@ func TestGetAppInstallStatus(t *testing.T) { }, AppConfig: &kotsv1beta1.Config{}, }), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -157,7 +158,7 @@ func TestGetAppInstallStatus(t *testing.T) { // Create simple Kubernetes install controller installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -294,7 +295,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(&store.MockStore{}), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -303,7 +304,7 @@ func TestPostInstallApp(t *testing.T) { kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), kubernetesinstall.WithReleaseData(releaseData), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -320,6 +321,7 @@ func TestPostInstallApp(t *testing.T) { api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -361,7 +363,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -370,7 +372,7 @@ func TestPostInstallApp(t *testing.T) { kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), kubernetesinstall.WithReleaseData(releaseData), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -387,6 +389,7 @@ func TestPostInstallApp(t *testing.T) { api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -433,7 +436,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -442,7 +445,7 @@ func TestPostInstallApp(t *testing.T) { kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), kubernetesinstall.WithReleaseData(releaseData), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -459,6 +462,7 @@ func TestPostInstallApp(t *testing.T) { api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -490,7 +494,7 @@ func TestPostInstallApp(t *testing.T) { // Create simple Kubernetes install controller installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithReleaseData(releaseData), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -544,7 +548,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -553,7 +557,7 @@ func TestPostInstallApp(t *testing.T) { kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), kubernetesinstall.WithReleaseData(releaseData), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -570,6 +574,7 @@ func TestPostInstallApp(t *testing.T) { api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -618,7 +623,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -627,7 +632,7 @@ func TestPostInstallApp(t *testing.T) { kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), kubernetesinstall.WithReleaseData(releaseData), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -644,6 +649,7 @@ func TestPostInstallApp(t *testing.T) { api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/kubernetes/install/apppreflight_test.go b/api/integration/kubernetes/install/apppreflight_test.go index 49bec6cd40..9acd901375 100644 --- a/api/integration/kubernetes/install/apppreflight_test.go +++ b/api/integration/kubernetes/install/apppreflight_test.go @@ -22,6 +22,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/kubernetesinstallation" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" + "github.com/replicatedhq/embedded-cluster/pkg/helm" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -72,7 +73,7 @@ func TestGetAppPreflightsStatus(t *testing.T) { appinstall.WithStateMachine(kubernetesinstall.NewStateMachine()), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -80,7 +81,7 @@ func TestGetAppPreflightsStatus(t *testing.T) { installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithAppInstallController(appInstallController), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -227,7 +228,7 @@ func TestPostRunAppPreflights(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -236,7 +237,7 @@ func TestPostRunAppPreflights(t *testing.T) { kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -252,6 +253,7 @@ func TestPostRunAppPreflights(t *testing.T) { api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -288,7 +290,7 @@ func TestPostRunAppPreflights(t *testing.T) { kubernetesinstall.WithCurrentState(states.StateNew), // Wrong state )), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -304,6 +306,7 @@ func TestPostRunAppPreflights(t *testing.T) { api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -334,7 +337,7 @@ func TestPostRunAppPreflights(t *testing.T) { // Create a basic install controller installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -350,6 +353,7 @@ func TestPostRunAppPreflights(t *testing.T) { api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/kubernetes/install/infra_test.go b/api/integration/kubernetes/install/infra_test.go index 8aca4569e4..b4c2618ee6 100644 --- a/api/integration/kubernetes/install/infra_test.go +++ b/api/integration/kubernetes/install/infra_test.go @@ -123,6 +123,7 @@ func TestKubernetesPostSetupInfra(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(states.StateInstallationConfigured))), kubernetesinstall.WithInfraManager(infraManager), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), kubernetesinstall.WithReleaseData(&release.ReleaseData{ EmbeddedClusterConfig: &ecv1beta1.Config{}, ChannelRelease: &release.ChannelRelease{ @@ -133,7 +134,6 @@ func TestKubernetesPostSetupInfra(t *testing.T) { }, AppConfig: &appConfig, }), - kubernetesinstall.WithK8sVersion("v1.33.0"), ) require.NoError(t, err) @@ -229,6 +229,7 @@ func TestKubernetesPostSetupInfra(t *testing.T) { // Test authorization t.Run("Authorization error", func(t *testing.T) { installController, err := kubernetesinstall.NewInstallController( + kubernetesinstall.WithHelmClient(&helm.MockClient{}), kubernetesinstall.WithReleaseData(&release.ReleaseData{ EmbeddedClusterConfig: &ecv1beta1.Config{}, ChannelRelease: &release.ChannelRelease{ @@ -239,7 +240,6 @@ func TestKubernetesPostSetupInfra(t *testing.T) { }, AppConfig: &appConfig, }), - kubernetesinstall.WithK8sVersion("v1.33.0"), ) require.NoError(t, err) @@ -319,6 +319,7 @@ func TestKubernetesPostSetupInfra(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(states.StateInstallationConfigured))), kubernetesinstall.WithInfraManager(infraManager), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), kubernetesinstall.WithReleaseData(&release.ReleaseData{ EmbeddedClusterConfig: &ecv1beta1.Config{}, ChannelRelease: &release.ChannelRelease{ @@ -329,7 +330,6 @@ func TestKubernetesPostSetupInfra(t *testing.T) { }, AppConfig: &appConfig, }), - kubernetesinstall.WithK8sVersion("v1.33.0"), ) require.NoError(t, err) diff --git a/api/integration/kubernetes/install/installation_test.go b/api/integration/kubernetes/install/installation_test.go index 1c17e812db..cadfe03190 100644 --- a/api/integration/kubernetes/install/installation_test.go +++ b/api/integration/kubernetes/install/installation_test.go @@ -21,6 +21,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/kubernetesinstallation" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -137,7 +138,7 @@ func TestKubernetesConfigureInstallation(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(states.StateApplicationConfigured))), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -229,7 +230,7 @@ func TestKubernetesConfigureInstallationValidation(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(states.StateApplicationConfigured))), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -288,7 +289,7 @@ func TestKubernetesConfigureInstallationBadRequest(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(states.StateApplicationConfigured))), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -373,7 +374,7 @@ func TestKubernetesGetInstallationConfig(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithInstallationManager(installationManager), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -436,7 +437,7 @@ func TestKubernetesGetInstallationConfig(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithInstallationManager(emptyInstallationManager), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), - kubernetesinstall.WithK8sVersion("v1.33.0"), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/linux/install/appconfig_test.go b/api/integration/linux/install/appconfig_test.go index f27279fa18..a776df0995 100644 --- a/api/integration/linux/install/appconfig_test.go +++ b/api/integration/linux/install/appconfig_test.go @@ -15,6 +15,7 @@ import ( states "github.com/replicatedhq/embedded-cluster/api/internal/states/install" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/kotskinds/multitype" @@ -65,6 +66,7 @@ func TestInstallController_PatchAppConfigValuesWithAPIClient(t *testing.T) { linuxinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -116,6 +118,7 @@ func TestInstallController_PatchAppConfigValuesWithAPIClient(t *testing.T) { linuxinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -219,6 +222,7 @@ func TestInstallController_GetAppConfigValuesWithAPIClient(t *testing.T) { linuxinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/linux/install/appinstall_test.go b/api/integration/linux/install/appinstall_test.go index 930e70adfb..22b02e6cd0 100644 --- a/api/integration/linux/install/appinstall_test.go +++ b/api/integration/linux/install/appinstall_test.go @@ -24,6 +24,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" @@ -100,7 +101,7 @@ func TestGetAppInstallStatus(t *testing.T) { appinstallmanager.WithAppInstallStore( appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(appInstallStatus)), ), - appinstallmanager.WithK8sVersion("v1.33.0"), + appinstallmanager.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -113,7 +114,7 @@ func TestGetAppInstallStatus(t *testing.T) { appinstall.WithStateMachine(linuxinstall.NewStateMachine()), appinstall.WithStore(mockStore), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -124,6 +125,7 @@ func TestGetAppInstallStatus(t *testing.T) { linuxinstall.WithReleaseData(releaseData), linuxinstall.WithRuntimeConfig(runtimeconfig.New(nil)), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -132,6 +134,7 @@ func TestGetAppInstallStatus(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) // Create a new router and register API routes @@ -181,6 +184,7 @@ func TestGetAppInstallStatus(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithReleaseData(releaseData), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -189,6 +193,7 @@ func TestGetAppInstallStatus(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) // Create a new router and register API routes @@ -216,6 +221,7 @@ func TestGetAppInstallStatus(t *testing.T) { api.WithLinuxInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) // Create a new router and register API routes @@ -327,7 +333,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(&store.MockStore{}), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -339,6 +345,7 @@ func TestPostInstallApp(t *testing.T) { linuxinstall.WithReleaseData(releaseData), linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -355,6 +362,7 @@ func TestPostInstallApp(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -401,7 +409,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -411,6 +419,7 @@ func TestPostInstallApp(t *testing.T) { linuxinstall.WithAppInstallController(appInstallController), linuxinstall.WithReleaseData(releaseData), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -427,6 +436,7 @@ func TestPostInstallApp(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -487,7 +497,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -500,6 +510,7 @@ func TestPostInstallApp(t *testing.T) { linuxinstall.WithReleaseData(releaseData), linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -516,6 +527,7 @@ func TestPostInstallApp(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -554,6 +566,7 @@ func TestPostInstallApp(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithReleaseData(releaseData), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -570,6 +583,7 @@ func TestPostInstallApp(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -620,7 +634,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -630,6 +644,7 @@ func TestPostInstallApp(t *testing.T) { linuxinstall.WithAppInstallController(appInstallController), linuxinstall.WithReleaseData(releaseData), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -646,6 +661,7 @@ func TestPostInstallApp(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -698,7 +714,7 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(releaseData), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -708,6 +724,7 @@ func TestPostInstallApp(t *testing.T) { linuxinstall.WithAppInstallController(appInstallController), linuxinstall.WithReleaseData(releaseData), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -724,6 +741,7 @@ func TestPostInstallApp(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/linux/install/apppreflight_test.go b/api/integration/linux/install/apppreflight_test.go index d05756b194..a55d259c44 100644 --- a/api/integration/linux/install/apppreflight_test.go +++ b/api/integration/linux/install/apppreflight_test.go @@ -21,6 +21,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" @@ -74,7 +75,7 @@ func TestGetAppPreflightsStatus(t *testing.T) { appinstall.WithStateMachine(linuxinstall.NewStateMachine()), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -82,6 +83,7 @@ func TestGetAppPreflightsStatus(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithAppInstallController(appInstallController), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -90,6 +92,7 @@ func TestGetAppPreflightsStatus(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) // Create a router and register the API routes @@ -145,6 +148,7 @@ func TestGetAppPreflightsStatus(t *testing.T) { api.WithLinuxInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) router := mux.NewRouter() @@ -227,7 +231,7 @@ func TestPostRunAppPreflights(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), - appinstall.WithK8sVersion("v1.33.0"), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -249,6 +253,7 @@ func TestPostRunAppPreflights(t *testing.T) { }), linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -264,6 +269,7 @@ func TestPostRunAppPreflights(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -311,6 +317,7 @@ func TestPostRunAppPreflights(t *testing.T) { }), linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -326,6 +333,7 @@ func TestPostRunAppPreflights(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -356,6 +364,7 @@ func TestPostRunAppPreflights(t *testing.T) { // Create a basic install controller installController, err := linuxinstall.NewInstallController( linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -371,6 +380,7 @@ func TestPostRunAppPreflights(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/linux/install/hostpreflight_test.go b/api/integration/linux/install/hostpreflight_test.go index 4307cddbcc..83985e8105 100644 --- a/api/integration/linux/install/hostpreflight_test.go +++ b/api/integration/linux/install/hostpreflight_test.go @@ -22,6 +22,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" @@ -71,6 +72,7 @@ func TestGetHostPreflightsStatus(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithHostPreflightManager(manager), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -218,6 +220,7 @@ func TestGetHostPreflightsStatusWithIgnoreFlag(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithHostPreflightManager(manager), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -234,6 +237,7 @@ func TestGetHostPreflightsStatusWithIgnoreFlag(t *testing.T) { api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -306,6 +310,7 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -404,6 +409,7 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -461,6 +467,7 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -519,6 +526,7 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -590,6 +598,7 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/linux/install/infra_test.go b/api/integration/linux/install/infra_test.go index a6907ef38b..f96c371e32 100644 --- a/api/integration/linux/install/infra_test.go +++ b/api/integration/linux/install/infra_test.go @@ -178,6 +178,7 @@ func TestLinuxPostSetupInfra(t *testing.T) { }, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -353,6 +354,7 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -411,6 +413,7 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -476,6 +479,7 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -540,6 +544,7 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -604,6 +609,7 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -689,6 +695,7 @@ func TestLinuxPostSetupInfra(t *testing.T) { }), linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(states.StateHostPreflightsSucceeded))), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/linux/install/installation_test.go b/api/integration/linux/install/installation_test.go index 0b854f631b..33983b55c2 100644 --- a/api/integration/linux/install/installation_test.go +++ b/api/integration/linux/install/installation_test.go @@ -25,6 +25,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -201,6 +202,7 @@ func TestLinuxConfigureInstallation(t *testing.T) { linuxinstall.WithHostUtils(tc.mockHostUtils), linuxinstall.WithNetUtils(tc.mockNetUtils), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -299,6 +301,7 @@ func TestLinuxConfigureInstallationValidation(t *testing.T) { linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(states.StateApplicationConfigured))), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -359,6 +362,7 @@ func TestLinuxConfigureInstallationBadRequest(t *testing.T) { linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(states.StateHostConfigured))), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -443,6 +447,7 @@ func TestLinuxGetInstallationConfig(t *testing.T) { linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithInstallationManager(installationManager), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -515,6 +520,7 @@ func TestLinuxGetInstallationConfig(t *testing.T) { linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithInstallationManager(emptyInstallationManager), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -636,6 +642,7 @@ func TestLinuxInstallationConfigWithAPIClient(t *testing.T) { linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(states.StateApplicationConfigured))), linuxinstall.WithInstallationManager(installationManager), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/integration/util.go b/api/integration/util.go index 2210af1391..a93f35ebe3 100644 --- a/api/integration/util.go +++ b/api/integration/util.go @@ -8,6 +8,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/stretchr/testify/require" @@ -63,7 +64,13 @@ func NewTargetLinuxAPIWithReleaseData(t *testing.T, opts ...api.Option) *api.API Password: "password", ReleaseData: DefaultReleaseData(), } - a, err := api.New(cfg, opts...) + + // Add default options + optsWithDefaults := append([]api.Option{ + api.WithHelmClient(&helm.MockClient{}), + }, opts...) + + a, err := api.New(cfg, optsWithDefaults...) require.NoError(t, err) return a } @@ -74,7 +81,13 @@ func NewTargetKubernetesAPIWithReleaseData(t *testing.T, opts ...api.Option) *ap Password: "password", ReleaseData: DefaultReleaseData(), } - a, err := api.New(cfg, opts...) + + // Add default options + optsWithDefaults := append([]api.Option{ + api.WithHelmClient(&helm.MockClient{}), + }, opts...) + + a, err := api.New(cfg, optsWithDefaults...) require.NoError(t, err) return a } diff --git a/api/internal/handlers/kubernetes/kubernetes.go b/api/internal/handlers/kubernetes/kubernetes.go index 14e17f8cf3..b431afe4c3 100644 --- a/api/internal/handlers/kubernetes/kubernetes.go +++ b/api/internal/handlers/kubernetes/kubernetes.go @@ -6,9 +6,9 @@ import ( "github.com/replicatedhq/embedded-cluster/api/controllers/kubernetes/install" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/sirupsen/logrus" - "k8s.io/cli-runtime/pkg/genericclioptions" ) type Handler struct { @@ -16,6 +16,7 @@ type Handler struct { installController install.Controller logger logrus.FieldLogger metricsReporter metrics.ReporterInterface + hcli helm.Client } type Option func(*Handler) @@ -38,6 +39,12 @@ func WithMetricsReporter(metricsReporter metrics.ReporterInterface) Option { } } +func WithHelmClient(hcli helm.Client) Option { + return func(h *Handler) { + h.hcli = hcli + } +} + func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { h := &Handler{ cfg: cfg, @@ -53,19 +60,9 @@ func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { // TODO (@team): discuss which of these should / should not be pointers if h.installController == nil { - var restClientGetter genericclioptions.RESTClientGetter - if ks := h.cfg.Installation.GetKubernetesEnvSettings(); ks != nil { - restClientGetter = ks.RESTClientGetter() - } - k8sVersion, err := getK8sVersion(restClientGetter) - if err != nil { - return nil, fmt.Errorf("get k8s version: %w", err) - } - installController, err := install.NewInstallController( install.WithLogger(h.logger), install.WithMetricsReporter(h.metricsReporter), - install.WithK8sVersion(k8sVersion), install.WithKubernetesEnvSettings(h.cfg.Installation.GetKubernetesEnvSettings()), install.WithReleaseData(h.cfg.ReleaseData), install.WithConfigValues(h.cfg.ConfigValues), @@ -73,6 +70,7 @@ func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { install.WithPassword(h.cfg.Password), //nolint:staticcheck // QF1008 this is very ambiguous, we should re-think the config struct install.WithInstallation(h.cfg.KubernetesConfig.Installation), + install.WithHelmClient(h.hcli), ) if err != nil { return nil, fmt.Errorf("new install controller: %w", err) diff --git a/api/internal/handlers/kubernetes/util.go b/api/internal/handlers/kubernetes/util.go deleted file mode 100644 index 6f9f613c2e..0000000000 --- a/api/internal/handlers/kubernetes/util.go +++ /dev/null @@ -1,23 +0,0 @@ -package kubernetes - -import ( - "fmt" - - "github.com/replicatedhq/embedded-cluster/api/internal/clients" - "k8s.io/cli-runtime/pkg/genericclioptions" -) - -// getK8sVersion creates a kubernetes client and returns the kubernetes version -func getK8sVersion(restClientGetter genericclioptions.RESTClientGetter) (string, error) { - kcli, err := clients.NewDiscoveryClient(clients.KubeClientOptions{ - RESTClientGetter: restClientGetter, - }) - if err != nil { - return "", fmt.Errorf("create discovery client: %w", err) - } - version, err := kcli.ServerVersion() - if err != nil { - return "", fmt.Errorf("get server version: %w", err) - } - return version.String(), nil -} diff --git a/api/internal/handlers/linux/linux.go b/api/internal/handlers/linux/linux.go index 803f83d586..ab9b6eda88 100644 --- a/api/internal/handlers/linux/linux.go +++ b/api/internal/handlers/linux/linux.go @@ -7,6 +7,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/pkg-new/hostutils" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/sirupsen/logrus" ) @@ -17,6 +18,7 @@ type Handler struct { logger logrus.FieldLogger hostUtils hostutils.HostUtilsInterface metricsReporter metrics.ReporterInterface + hcli helm.Client } type Option func(*Handler) @@ -45,6 +47,12 @@ func WithMetricsReporter(metricsReporter metrics.ReporterInterface) Option { } } +func WithHelmClient(hcli helm.Client) Option { + return func(h *Handler) { + h.hcli = hcli + } +} + func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { h := &Handler{ cfg: cfg, @@ -82,6 +90,7 @@ func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { install.WithEndUserConfig(h.cfg.EndUserConfig), install.WithClusterID(h.cfg.ClusterID), install.WithAllowIgnoreHostPreflights(h.cfg.AllowIgnoreHostPreflights), + install.WithHelmClient(h.hcli), ) if err != nil { return nil, fmt.Errorf("new install controller: %w", err) diff --git a/api/internal/managers/app/install/install.go b/api/internal/managers/app/install/install.go index 6e2cb0550f..333b9c8faa 100644 --- a/api/internal/managers/app/install/install.go +++ b/api/internal/managers/app/install/install.go @@ -136,11 +136,6 @@ func (m *appInstallManager) installHelmCharts(ctx context.Context, installableCh return fmt.Errorf("no helm charts found") } - // Setup Helm client - if err := m.setupHelmClient(); err != nil { - return fmt.Errorf("setup helm client: %w", err) - } - logFn("installing %d helm charts", len(installableCharts)) for _, installableChart := range installableCharts { @@ -201,6 +196,7 @@ func (m *appInstallManager) installHelmChart(ctx context.Context, installableCha Namespace: namespace, ReleaseName: installableChart.CR.GetReleaseName(), Values: installableChart.Values, + LogFn: m.logFn("helm"), }) if err != nil { return err // do not wrap as wrapping is repetitive, e.g. "helm install: helm install: context deadline exceeded" diff --git a/api/internal/managers/app/install/install_test.go b/api/internal/managers/app/install/install_test.go index e363531782..2db7e43bb0 100644 --- a/api/internal/managers/app/install/install_test.go +++ b/api/internal/managers/app/install/install_test.go @@ -180,7 +180,6 @@ func TestAppInstallManager_Install(t *testing.T) { WithClusterID("test-cluster"), WithAirgapBundle("test-airgap.tar.gz"), WithReleaseData(releaseData), - WithK8sVersion("v1.33.0"), WithKotsCLI(mockInstaller), WithHelmClient(mockHelmClient), WithLogger(logger.NewDiscardLogger()), @@ -218,7 +217,6 @@ func TestAppInstallManager_Install(t *testing.T) { WithLicense(licenseBytes), WithClusterID("test-cluster"), WithReleaseData(releaseData), - WithK8sVersion("v1.33.0"), WithKotsCLI(mockInstaller), WithHelmClient(mockHelmClient), WithLogger(logger.NewDiscardLogger()), @@ -268,7 +266,6 @@ func TestAppInstallManager_Install(t *testing.T) { WithLicense(licenseBytes), WithClusterID("test-cluster"), WithReleaseData(releaseData), - WithK8sVersion("v1.33.0"), WithKotsCLI(mockInstaller), WithHelmClient(mockHelmClient), WithLogger(logger.NewDiscardLogger()), @@ -305,7 +302,7 @@ func TestAppInstallManager_Install(t *testing.T) { manager, err := NewAppInstallManager( WithLogger(logger.NewDiscardLogger()), WithAppInstallStore(store), - WithK8sVersion("v1.33.0"), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -450,7 +447,6 @@ func TestComponentStatusTracking(t *testing.T) { manager, err := NewAppInstallManager( WithAppInstallStore(appInstallStore), WithReleaseData(&release.ReleaseData{}), - WithK8sVersion("v1.33.0"), WithLicense([]byte(`{"spec":{"appSlug":"test-app"}}`)), WithClusterID("test-cluster"), WithKotsCLI(mockInstaller), @@ -507,7 +503,6 @@ func TestComponentStatusTracking(t *testing.T) { manager, err := NewAppInstallManager( WithAppInstallStore(appInstallStore), WithReleaseData(&release.ReleaseData{}), - WithK8sVersion("v1.33.0"), WithLicense([]byte(`{"spec":{"appSlug":"test-app"}}`)), WithClusterID("test-cluster"), WithKotsCLI(mockInstaller), diff --git a/api/internal/managers/app/install/manager.go b/api/internal/managers/app/install/manager.go index 5ed62ea5aa..b064f01e03 100644 --- a/api/internal/managers/app/install/manager.go +++ b/api/internal/managers/app/install/manager.go @@ -12,7 +12,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" - helmcli "helm.sh/helm/v3/pkg/cli" ) var _ AppInstallManager = &appInstallManager{} @@ -32,16 +31,14 @@ type AppInstallManager interface { // appInstallManager is an implementation of the AppInstallManager interface type appInstallManager struct { - appInstallStore appinstallstore.Store - releaseData *release.ReleaseData - license []byte - clusterID string - airgapBundle string - kotsCLI KotsCLIInstaller - logger logrus.FieldLogger - hcli helm.Client - k8sVersion string - kubernetesEnvSettings *helmcli.EnvSettings + appInstallStore appinstallstore.Store + releaseData *release.ReleaseData + license []byte + clusterID string + airgapBundle string + kotsCLI KotsCLIInstaller + logger logrus.FieldLogger + hcli helm.Client } type AppInstallManagerOption func(*appInstallManager) @@ -95,18 +92,6 @@ func WithHelmClient(hcli helm.Client) AppInstallManagerOption { } } -func WithK8sVersion(k8sVersion string) AppInstallManagerOption { - return func(m *appInstallManager) { - m.k8sVersion = k8sVersion - } -} - -func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) AppInstallManagerOption { - return func(m *appInstallManager) { - m.kubernetesEnvSettings = envSettings - } -} - // NewAppInstallManager creates a new AppInstallManager with the provided options func NewAppInstallManager(opts ...AppInstallManagerOption) (*appInstallManager, error) { manager := &appInstallManager{} @@ -115,14 +100,14 @@ func NewAppInstallManager(opts ...AppInstallManagerOption) (*appInstallManager, opt(manager) } - if manager.k8sVersion == "" { - return nil, fmt.Errorf("k8s version required") - } - if manager.logger == nil { manager.logger = logger.NewDiscardLogger() } + if manager.hcli == nil { + return nil, fmt.Errorf("helm client is required") + } + if manager.appInstallStore == nil { manager.appInstallStore = appinstallstore.NewMemoryStore() } diff --git a/api/internal/managers/app/install/util.go b/api/internal/managers/app/install/util.go index 618da04408..1ac4312cd8 100644 --- a/api/internal/managers/app/install/util.go +++ b/api/internal/managers/app/install/util.go @@ -6,8 +6,6 @@ import ( "os" "regexp" "strings" - - "github.com/replicatedhq/embedded-cluster/pkg/helm" ) // logWriter is an io.Writer that captures output and feeds it to the logs @@ -33,23 +31,6 @@ func (lw *logWriter) Write(p []byte) (n int, err error) { return len(p), nil } -func (m *appInstallManager) setupHelmClient() error { - if m.hcli != nil { - return nil - } - - hcli, err := helm.NewClient(helm.HelmOptions{ - KubernetesEnvSettings: m.kubernetesEnvSettings, - K8sVersion: m.k8sVersion, - LogFn: m.logFn("helm"), - }) - if err != nil { - return fmt.Errorf("create helm client: %w", err) - } - m.hcli = hcli - return nil -} - func (m *appInstallManager) logFn(component string) func(format string, v ...interface{}) { return func(format string, v ...interface{}) { m.logger.WithField("component", component).Debugf(format, v...) diff --git a/api/internal/managers/app/release/manager.go b/api/internal/managers/app/release/manager.go index d4a3ff6053..6b1dab542d 100644 --- a/api/internal/managers/app/release/manager.go +++ b/api/internal/managers/app/release/manager.go @@ -29,7 +29,6 @@ type appReleaseManager struct { logger logrus.FieldLogger privateCACertConfigMapName string hcli helm.Client - k8sVersion string } type AppReleaseManagerOption func(*appReleaseManager) @@ -70,12 +69,6 @@ func WithHelmClient(hcli helm.Client) AppReleaseManagerOption { } } -func WithK8sVersion(k8sVersion string) AppReleaseManagerOption { - return func(m *appReleaseManager) { - m.k8sVersion = k8sVersion - } -} - // NewAppReleaseManager creates a new AppReleaseManager func NewAppReleaseManager(config kotsv1beta1.Config, opts ...AppReleaseManagerOption) (AppReleaseManager, error) { manager := &appReleaseManager{ @@ -89,8 +82,9 @@ func NewAppReleaseManager(config kotsv1beta1.Config, opts ...AppReleaseManagerOp if manager.releaseData == nil { return nil, fmt.Errorf("release data not found") } - if manager.k8sVersion == "" { - return nil, fmt.Errorf("k8s version required") + + if manager.hcli == nil { + return nil, fmt.Errorf("helm client is required") } if manager.logger == nil { diff --git a/api/internal/managers/app/release/template.go b/api/internal/managers/app/release/template.go index a47448d2fa..88ae81db52 100644 --- a/api/internal/managers/app/release/template.go +++ b/api/internal/managers/app/release/template.go @@ -208,9 +208,6 @@ func (m *appReleaseManager) dryRunHelmChart(ctx context.Context, templatedCR *ko } // Perform dry run rendering - if err := m.setupHelmClient(); err != nil { - return nil, fmt.Errorf("setup helm client: %w", err) - } manifests, err := m.hcli.Render(ctx, installOpts) if err != nil { diff --git a/api/internal/managers/app/release/template_test.go b/api/internal/managers/app/release/template_test.go index 17d84d85f1..35f2b29231 100644 --- a/api/internal/managers/app/release/template_test.go +++ b/api/internal/managers/app/release/template_test.go @@ -7,6 +7,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" @@ -322,8 +323,6 @@ spec: }, } - t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create release data @@ -332,12 +331,17 @@ spec: HelmChartArchives: tt.chartArchives, } - // Create manager + // Create real helm client config := createTestConfig() + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", // TODO NOW: check that is gets used + }) + require.NoError(t, err) manager, err := NewAppReleaseManager( config, WithReleaseData(releaseData), - WithK8sVersion("v1.33.0"), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -860,7 +864,7 @@ spec: manager, err := NewAppReleaseManager( config, WithReleaseData(releaseData), - WithK8sVersion("1.33.0"), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -1124,8 +1128,6 @@ spec: }, } - t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a basic config for the template engine @@ -1135,10 +1137,16 @@ spec: releaseData := &release.ReleaseData{ HelmChartArchives: tt.helmChartArchives, } + // Create real helm client + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", + }) + require.NoError(t, err) manager, err := NewAppReleaseManager( config, WithReleaseData(releaseData), - WithK8sVersion("v1.33.0"), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -2515,7 +2523,7 @@ spec: manager, err := NewAppReleaseManager( config, WithReleaseData(releaseData), - WithK8sVersion("v1.33.0"), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) diff --git a/api/internal/managers/app/release/util.go b/api/internal/managers/app/release/util.go index c64b9c5a74..e4f74e0229 100644 --- a/api/internal/managers/app/release/util.go +++ b/api/internal/managers/app/release/util.go @@ -5,28 +5,11 @@ import ( "fmt" "os" - "github.com/replicatedhq/embedded-cluster/pkg/helm" kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" "helm.sh/helm/v3/pkg/chart/loader" ) -func (m *appReleaseManager) setupHelmClient() error { - if m.hcli != nil { - return nil - } - - hcli, err := helm.NewClient(helm.HelmOptions{ - // hcli.Render doesn't need a kubeconfig as it is client only - K8sVersion: m.k8sVersion, - }) - if err != nil { - return fmt.Errorf("create helm client: %w", err) - } - m.hcli = hcli - return nil -} - // findChartArchive finds the chart archive that corresponds to the given HelmChart CR func findChartArchive(helmChartArchives [][]byte, templatedCR *kotsv1beta2.HelmChart) ([]byte, error) { if len(helmChartArchives) == 0 { diff --git a/api/internal/managers/kubernetes/infra/manager.go b/api/internal/managers/kubernetes/infra/manager.go index cdb2269c50..d6b8ffa087 100644 --- a/api/internal/managers/kubernetes/infra/manager.go +++ b/api/internal/managers/kubernetes/infra/manager.go @@ -168,16 +168,7 @@ func NewInfraManager(opts ...InfraManagerOption) (*infraManager, error) { } if manager.hcli == nil { - hcli, err := helm.NewClient(helm.HelmOptions{ - KubernetesEnvSettings: manager.kubernetesEnvSettings, - // TODO: how can we support airgap? - AirgapPath: "", - LogFn: manager.logFn("helm"), - }) - if err != nil { - return nil, fmt.Errorf("create helm client: %w", err) - } - manager.hcli = hcli + return nil, fmt.Errorf("helm client is required") } return manager, nil diff --git a/api/internal/managers/kubernetes/infra/manager_test.go b/api/internal/managers/kubernetes/infra/manager_test.go index 6d8ca9ef6e..a858e126b7 100644 --- a/api/internal/managers/kubernetes/infra/manager_test.go +++ b/api/internal/managers/kubernetes/infra/manager_test.go @@ -21,44 +21,28 @@ func TestNewInfraManager_ClientCreation(t *testing.T) { expectError bool }{ { - name: "creates all clients when none provided", - expectError: false, + name: "fails when helm client not provided", + expectError: true, }, { - name: "creates kube and metadata clients when helm client provided", + name: "creates kube and metadata clients when only helm client provided", withHelmClient: true, expectError: false, }, { - name: "creates kube and helm clients when metadata client provided", - withMetadataClient: true, - expectError: false, - }, - { - name: "creates metadata and helm clients when kube client provided", - withKubeClient: true, - expectError: false, - }, - { - name: "creates only helm client when kube and metadata clients provided", - withKubeClient: true, - withMetadataClient: true, - expectError: false, - }, - { - name: "creates only metadata client when kube and helm clients provided", + name: "creates metadata client when kube and helm clients provided", withKubeClient: true, withHelmClient: true, expectError: false, }, { - name: "creates only kube client when metadata and helm clients provided", + name: "creates kube client when metadata and helm clients provided", withMetadataClient: true, withHelmClient: true, expectError: false, }, { - name: "creates no clients when all provided", + name: "uses all provided clients when all are given", withKubeClient: true, withMetadataClient: true, withHelmClient: true, @@ -66,8 +50,6 @@ func TestNewInfraManager_ClientCreation(t *testing.T) { }, } - t.Setenv("HELM_BINARY_PATH", "helm") // use the helm binary in PATH - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Build options @@ -83,7 +65,13 @@ func TestNewInfraManager_ClientCreation(t *testing.T) { opts = append(opts, WithMetadataClient(metadatafake.NewSimpleMetadataClient(scheme.Scheme))) } if tt.withHelmClient { - opts = append(opts, WithHelmClient(&helm.MockClient{})) + // Create real helm client + hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: "helm", + K8sVersion: "v1.26.0", + }) + require.NoError(t, err) + opts = append(opts, WithHelmClient(hcli)) } // Create manager diff --git a/api/internal/managers/kubernetes/infra/status_test.go b/api/internal/managers/kubernetes/infra/status_test.go index e666733d3a..e3eb8f8537 100644 --- a/api/internal/managers/kubernetes/infra/status_test.go +++ b/api/internal/managers/kubernetes/infra/status_test.go @@ -3,6 +3,7 @@ package infra import ( "testing" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metadatafake "k8s.io/client-go/metadata/fake" @@ -11,7 +12,7 @@ import ( ) func TestInfraWithLogs(t *testing.T) { - manager, err := NewInfraManager(WithKubeClient(fake.NewFakeClient()), WithMetadataClient(metadatafake.NewSimpleMetadataClient(scheme.Scheme))) + manager, err := NewInfraManager(WithKubeClient(fake.NewFakeClient()), WithMetadataClient(metadatafake.NewSimpleMetadataClient(scheme.Scheme)), WithHelmClient(&helm.MockClient{})) require.NoError(t, err) // Add some logs through the internal logging mechanism diff --git a/api/internal/managers/linux/infra/install.go b/api/internal/managers/linux/infra/install.go index 51c8ddd925..ce0d029117 100644 --- a/api/internal/managers/linux/infra/install.go +++ b/api/internal/managers/linux/infra/install.go @@ -176,7 +176,7 @@ func (m *infraManager) installK0s(ctx context.Context, rc runtimeconfig.RuntimeC } // initialize the manager's helm and kube clients - err = m.setupClients(rc.GetKubernetesEnvSettings(), rc.EmbeddedClusterChartsSubDir()) + err = m.setupClients(rc) if err != nil { return nil, fmt.Errorf("setup clients: %w", err) } diff --git a/api/internal/managers/linux/infra/util.go b/api/internal/managers/linux/infra/util.go index 24875422b9..a9c1d8e601 100644 --- a/api/internal/managers/linux/infra/util.go +++ b/api/internal/managers/linux/infra/util.go @@ -8,10 +8,8 @@ import ( "github.com/replicatedhq/embedded-cluster/api/internal/clients" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" - "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/kubeutils" - "github.com/replicatedhq/embedded-cluster/pkg/versions" - helmcli "helm.sh/helm/v3/pkg/cli" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "k8s.io/cli-runtime/pkg/genericclioptions" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -30,10 +28,10 @@ func (m *infraManager) waitForNode(ctx context.Context, kcli client.Client) erro // setupClients initializes the kube, metadata, and helm clients if they are not already set. // We need to do it after the infra manager is initialized to ensure that the runtime config is available and we already have a cluster setup -func (m *infraManager) setupClients(kubernetesEnvSettings *helmcli.EnvSettings, airgapChartsPath string) error { +func (m *infraManager) setupClients(rc runtimeconfig.RuntimeConfig) error { var restClientGetter genericclioptions.RESTClientGetter - if kubernetesEnvSettings != nil { - restClientGetter = kubernetesEnvSettings.RESTClientGetter() + if rc.GetKubernetesEnvSettings() != nil { + restClientGetter = rc.GetKubernetesEnvSettings().RESTClientGetter() } if m.kcli == nil { @@ -53,21 +51,7 @@ func (m *infraManager) setupClients(kubernetesEnvSettings *helmcli.EnvSettings, } if m.hcli == nil { - airgapPath := "" - if m.airgapBundle != "" { - airgapPath = airgapChartsPath - } - - hcli, err := helm.NewClient(helm.HelmOptions{ - KubernetesEnvSettings: kubernetesEnvSettings, - K8sVersion: versions.K0sVersion, - AirgapPath: airgapPath, - LogFn: m.logFn("helm"), - }) - if err != nil { - return fmt.Errorf("create helm client: %w", err) - } - m.hcli = hcli + return fmt.Errorf("helm client is required") } return nil diff --git a/cmd/installer/cli/api_test.go b/cmd/installer/cli/api_test.go index b3b0d653f5..c1c73519f0 100644 --- a/cmd/installer/cli/api_test.go +++ b/cmd/installer/cli/api_test.go @@ -15,6 +15,7 @@ import ( apitypes "github.com/replicatedhq/embedded-cluster/api/types" "github.com/replicatedhq/embedded-cluster/pkg-new/tlsutils" "github.com/replicatedhq/embedded-cluster/pkg/release" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -55,6 +56,10 @@ func Test_serveAPI(t *testing.T) { portInt, err := strconv.Atoi(port) require.NoError(t, err) + // Create a runtime config with temp directory + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + config := apiOptions{ APIConfig: apitypes.APIConfig{ InstallTarget: apitypes.InstallTargetLinux, @@ -68,6 +73,9 @@ func Test_serveAPI(t *testing.T) { }, }, ClusterID: "123", + LinuxConfig: apitypes.LinuxConfig{ + RuntimeConfig: rc, + }, }, ManagerPort: portInt, Logger: apilogger.NewDiscardLogger(), diff --git a/cmd/installer/cli/enable_ha.go b/cmd/installer/cli/enable_ha.go index caa2348905..89f25c8c5a 100644 --- a/cmd/installer/cli/enable_ha.go +++ b/cmd/installer/cli/enable_ha.go @@ -80,6 +80,7 @@ func runEnableHA(ctx context.Context, rc runtimeconfig.RuntimeConfig) error { } hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), K8sVersion: versions.K0sVersion, AirgapPath: airgapChartsPath, diff --git a/cmd/installer/cli/install.go b/cmd/installer/cli/install.go index db86bc1ab1..924c4e2cd1 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -798,6 +798,7 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run } hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), K8sVersion: versions.K0sVersion, AirgapPath: airgapChartsPath, diff --git a/cmd/installer/cli/join.go b/cmd/installer/cli/join.go index 21629a7248..71c475b03e 100644 --- a/cmd/installer/cli/join.go +++ b/cmd/installer/cli/join.go @@ -610,6 +610,7 @@ func maybeEnableHA(ctx context.Context, kcli client.Client, mcli metadata.Interf airgapChartsPath = rc.EmbeddedClusterChartsSubDir() } hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), K8sVersion: versions.K0sVersion, AirgapPath: airgapChartsPath, diff --git a/cmd/installer/cli/restore.go b/cmd/installer/cli/restore.go index 648f0107ae..c52ccd1157 100644 --- a/cmd/installer/cli/restore.go +++ b/cmd/installer/cli/restore.go @@ -405,6 +405,7 @@ func runRestoreStepNew(ctx context.Context, appSlug, appTitle string, flags Inst } hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), K8sVersion: versions.K0sVersion, AirgapPath: airgapChartsPath, @@ -612,6 +613,7 @@ func runRestoreEnableAdminConsoleHA(ctx context.Context, flags InstallCmdFlags, } hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), K8sVersion: versions.K0sVersion, AirgapPath: airgapChartsPath, @@ -710,6 +712,7 @@ func runRestoreExtensions(ctx context.Context, flags InstallCmdFlags, rc runtime } hcli, err := helm.NewClient(helm.HelmOptions{ + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), K8sVersion: versions.K0sVersion, AirgapPath: airgapChartsPath, diff --git a/pkg/addons/adminconsole/integration/hostcabundle_test.go b/pkg/addons/adminconsole/integration/hostcabundle_test.go index 41fc7f902c..3e731b23aa 100644 --- a/pkg/addons/adminconsole/integration/hostcabundle_test.go +++ b/pkg/addons/adminconsole/integration/hostcabundle_test.go @@ -28,7 +28,8 @@ func TestHostCABundle(t *testing.T) { require.NoError(t, err, "Failed to write CA bundle file") hcli, err := helm.NewClient(helm.HelmOptions{ - HelmPath: "helm", // use the helm binary in PATH + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.26.0", }) require.NoError(t, err, "NewClient should not return an error") diff --git a/pkg/addons/adminconsole/integration/kubernetes_test.go b/pkg/addons/adminconsole/integration/kubernetes_test.go index 42bd4b81f5..a503196cf5 100644 --- a/pkg/addons/adminconsole/integration/kubernetes_test.go +++ b/pkg/addons/adminconsole/integration/kubernetes_test.go @@ -32,7 +32,8 @@ func TestKubernetes_Airgap(t *testing.T) { } hcli, err := helm.NewClient(helm.HelmOptions{ - HelmPath: "helm", // use the helm binary in PATH + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.26.0", }) require.NoError(t, err, "NewClient should not return an error") diff --git a/pkg/addons/adminconsole/integration/linux_test.go b/pkg/addons/adminconsole/integration/linux_test.go index 2b49a02757..70fe6b594a 100644 --- a/pkg/addons/adminconsole/integration/linux_test.go +++ b/pkg/addons/adminconsole/integration/linux_test.go @@ -46,7 +46,8 @@ func TestLinux_Airgap(t *testing.T) { require.NoError(t, err, "Failed to write CA bundle file") hcli, err := helm.NewClient(helm.HelmOptions{ - HelmPath: "helm", // use the helm binary in PATH + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.26.0", }) require.NoError(t, err, "NewClient should not return an error") diff --git a/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go index 2ad923b2e5..8ab4dac29f 100644 --- a/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go +++ b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go @@ -29,7 +29,8 @@ func TestHostCABundle(t *testing.T) { } hcli, err := helm.NewClient(helm.HelmOptions{ - HelmPath: "helm", // use the helm binary in PATH + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.26.0", }) require.NoError(t, err, "NewClient should not return an error") diff --git a/pkg/addons/velero/integration/hostcabundle_test.go b/pkg/addons/velero/integration/hostcabundle_test.go index ddbd1b0c19..be19c27e0a 100644 --- a/pkg/addons/velero/integration/hostcabundle_test.go +++ b/pkg/addons/velero/integration/hostcabundle_test.go @@ -23,7 +23,8 @@ func TestHostCABundle(t *testing.T) { } hcli, err := helm.NewClient(helm.HelmOptions{ - HelmPath: "helm", // use the helm binary in PATH + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.26.0", }) require.NoError(t, err, "NewClient should not return an error") diff --git a/pkg/addons/velero/integration/k0ssubdir_test.go b/pkg/addons/velero/integration/k0ssubdir_test.go index 0f9df2d44d..79755ccb1c 100644 --- a/pkg/addons/velero/integration/k0ssubdir_test.go +++ b/pkg/addons/velero/integration/k0ssubdir_test.go @@ -25,7 +25,8 @@ func TestK0sDir(t *testing.T) { } hcli, err := helm.NewClient(helm.HelmOptions{ - HelmPath: "helm", // use the helm binary in PATH + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.26.0", }) require.NoError(t, err, "NewClient should not return an error") diff --git a/pkg/helm/binary_executor.go b/pkg/helm/binary_executor.go index edca0d236a..e73b2468ac 100644 --- a/pkg/helm/binary_executor.go +++ b/pkg/helm/binary_executor.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "io" + "regexp" + "strings" "github.com/replicatedhq/embedded-cluster/pkg/helpers" ) @@ -12,24 +14,23 @@ import ( // This interface is mockable for testing purposes. type BinaryExecutor interface { // ExecuteCommand runs a command and returns stdout, stderr, and error - ExecuteCommand(ctx context.Context, env map[string]string, args ...string) (stdout string, stderr string, err error) + ExecuteCommand(ctx context.Context, env map[string]string, logFn LogFn, args ...string) (stdout string, stderr string, err error) } // binaryExecutor implements BinaryExecutor using helpers.RunCommandWithOptions type binaryExecutor struct { - bin string // Path to the binary to execute - logFn LogFn // Optional logging function + bin string // Path to the binary to execute } // newBinaryExecutor creates a new binaryExecutor with the specified binary path -func newBinaryExecutor(bin string, logFn LogFn) BinaryExecutor { - return &binaryExecutor{bin: bin, logFn: logFn} +func newBinaryExecutor(bin string) BinaryExecutor { + return &binaryExecutor{bin: bin} } // ExecuteCommand runs a command using helpers.RunCommandWithOptions and returns stdout, stderr, and error -func (c *binaryExecutor) ExecuteCommand(ctx context.Context, env map[string]string, args ...string) (string, string, error) { +func (c *binaryExecutor) ExecuteCommand(ctx context.Context, env map[string]string, logFn LogFn, args ...string) (string, string, error) { var stdout, stderr bytes.Buffer - logWriter := &logWriter{logFn: c.logFn} + logWriter := &logWriter{logFn: logFn} err := helpers.RunCommandWithOptions(helpers.RunCommandOptions{ Context: ctx, @@ -46,9 +47,15 @@ type logWriter struct { logFn LogFn } +// match log lines that come from go files to reduce noise and keep the logs relevant and readable to the user +var goFilePattern = regexp.MustCompile(`^\w+\.go:\d+:`) + func (lw *logWriter) Write(p []byte) (n int, err error) { if lw.logFn != nil && len(p) > 0 { - lw.logFn("%s", string(p)) + line := strings.TrimSpace(string(p)) + if line != "" && goFilePattern.MatchString(line) { + lw.logFn("%s", line) + } } return len(p), nil } diff --git a/pkg/helm/binary_executor_mock.go b/pkg/helm/binary_executor_mock.go index e88805caef..0453f064e4 100644 --- a/pkg/helm/binary_executor_mock.go +++ b/pkg/helm/binary_executor_mock.go @@ -14,7 +14,7 @@ type MockBinaryExecutor struct { } // ExecuteCommand mocks the ExecuteCommand method -func (m *MockBinaryExecutor) ExecuteCommand(ctx context.Context, env map[string]string, args ...string) (string, string, error) { - callArgs := m.Called(ctx, env, args) +func (m *MockBinaryExecutor) ExecuteCommand(ctx context.Context, env map[string]string, logFn LogFn, args ...string) (string, string, error) { + callArgs := m.Called(ctx, env, logFn, args) return callArgs.String(0), callArgs.String(1), callArgs.Error(2) } diff --git a/pkg/helm/binary_executor_test.go b/pkg/helm/binary_executor_test.go index 25c0b9e8b0..ba6f4b03ab 100644 --- a/pkg/helm/binary_executor_test.go +++ b/pkg/helm/binary_executor_test.go @@ -32,8 +32,8 @@ func Test_binaryExecutor_ExecuteCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - executor := newBinaryExecutor(tt.bin, nil) - stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, tt.args...) + executor := newBinaryExecutor(tt.bin) + stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, nil, tt.args...) if tt.wantErr { assert.Error(t, err) @@ -75,7 +75,16 @@ func Test_binaryExecutor_ExecuteCommand_WithLogging(t *testing.T) { wantErr: false, expectedStdout: "stdout message\n", expectedStderr: "stderr message\n", - expectedLogs: []string{"stderr message\n"}, // Only stderr is logged + expectedLogs: []string{}, // No logs expected since stderr doesn't match .go file pattern + }, + { + name: "command with go file pattern in stderr", + bin: "sh", + args: []string{"-c", "echo 'stdout message'; echo 'install.go:225: debug message' >&2"}, + wantErr: false, + expectedStdout: "stdout message\n", + expectedStderr: "install.go:225: debug message\n", + expectedLogs: []string{"install.go:225: debug message"}, // Go file pattern should be logged }, } @@ -86,8 +95,8 @@ func Test_binaryExecutor_ExecuteCommand_WithLogging(t *testing.T) { logs = append(logs, fmt.Sprintf(format, v...)) } - executor := newBinaryExecutor(tt.bin, logFn) - stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, tt.args...) + executor := newBinaryExecutor(tt.bin) + stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, logFn, tt.args...) if tt.wantErr { assert.Error(t, err) @@ -114,13 +123,20 @@ func Test_logWriter_Write(t *testing.T) { writer := &logWriter{logFn: logFn} - // Test writing data - n, err := writer.Write([]byte("test message")) + // Test writing data that matches .go file pattern + n, err := writer.Write([]byte("install.go:225: test message")) assert.NoError(t, err) - assert.Equal(t, 12, n) + assert.Equal(t, 28, n) assert.Len(t, loggedMessages, 1) assert.Equal(t, "%s", loggedMessages[0]) + // Test writing data that doesn't match .go file pattern (should be filtered out) + loggedMessages = nil + n, err = writer.Write([]byte("verbose debug message")) + assert.NoError(t, err) + assert.Equal(t, 21, n) + assert.Len(t, loggedMessages, 0) // Should be filtered out + // Test writing empty data loggedMessages = nil n, err = writer.Write([]byte{}) @@ -151,6 +167,7 @@ func Test_MockBinaryExecutor_ExecuteCommand(t *testing.T) { m.On("ExecuteCommand", mock.Anything, map[string]string{"TEST": "value"}, + mock.Anything, // LogFn []string{"version"}, ).Return("v3.12.0", "", nil) }, @@ -166,6 +183,7 @@ func Test_MockBinaryExecutor_ExecuteCommand(t *testing.T) { m.On("ExecuteCommand", mock.Anything, mock.Anything, + mock.Anything, // LogFn []string{"invalid"}, ).Return("", "command not found", assert.AnError) }, @@ -182,7 +200,7 @@ func Test_MockBinaryExecutor_ExecuteCommand(t *testing.T) { mock := &MockBinaryExecutor{} tt.setupMock(mock) - stdout, stderr, err := mock.ExecuteCommand(t.Context(), tt.env, tt.args...) + stdout, stderr, err := mock.ExecuteCommand(t.Context(), tt.env, nil, tt.args...) if tt.expectedErr != nil { assert.Error(t, err) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index fe3339a773..4301899068 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -10,7 +10,6 @@ import ( "time" "github.com/Masterminds/semver/v3" - "github.com/replicatedhq/embedded-cluster/cmd/installer/goods" "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" "helm.sh/helm/v3/pkg/chart" @@ -28,11 +27,6 @@ func newClient(opts HelmOptions) (*HelmClient, error) { return nil, err } - helmPath, err := getHelmPath(opts) - if err != nil { - return nil, fmt.Errorf("get helm path: %w", err) - } - var kversion *semver.Version if opts.K8sVersion != "" { sv, err := semver.NewVersion(opts.K8sVersion) @@ -43,41 +37,26 @@ func newClient(opts HelmOptions) (*HelmClient, error) { } return &HelmClient{ - helmPath: helmPath, - executor: newBinaryExecutor(helmPath, opts.LogFn), + helmPath: opts.HelmPath, + executor: newBinaryExecutor(opts.HelmPath), tmpdir: tmpdir, kversion: kversion, kubernetesEnvSettings: opts.KubernetesEnvSettings, airgapPath: opts.AirgapPath, repositories: []*repo.Entry{}, + logFn: opts.LogFn, }, nil } -func getHelmPath(opts HelmOptions) (string, error) { - if opts.HelmPath != "" { - // used in operations that do not have the helm binary embedded (i.e. upgrade via operator, etc..) - return opts.HelmPath, nil - } - if hp := os.Getenv("HELM_BINARY_PATH"); hp != "" { - // used in tests so that we don't pass the helm binary path all over the place - return hp, nil - } - hp, err := goods.Binary("helm") - if err != nil { - return "", fmt.Errorf("get embedded helm binary: %w", err) - } - return hp, nil -} - type HelmOptions struct { - HelmPath string + HelmPath string // Required: Path to the helm binary KubernetesEnvSettings *helmcli.EnvSettings K8sVersion string AirgapPath string - LogFn LogFn + LogFn LogFn // Global log function to use for all commands } -type LogFn func(format string, v ...interface{}) +type LogFn func(format string, args ...interface{}) type InstallOptions struct { ReleaseName string @@ -87,6 +66,7 @@ type InstallOptions struct { Namespace string Labels map[string]string Timeout time.Duration + LogFn LogFn // Log function override to use for install command } type UpgradeOptions struct { @@ -98,6 +78,7 @@ type UpgradeOptions struct { Labels map[string]string Timeout time.Duration Force bool + LogFn LogFn // Log function override to use for upgrade command } type UninstallOptions struct { @@ -105,6 +86,7 @@ type UninstallOptions struct { Namespace string Wait bool IgnoreNotFound bool + LogFn LogFn // Log function override to use for uninstall command } type HelmClient struct { @@ -115,13 +97,14 @@ type HelmClient struct { kubernetesEnvSettings *helmcli.EnvSettings // Kubernetes environment settings airgapPath string // Airgap path where charts are stored repositories []*repo.Entry // Repository entries for helm repo commands + logFn LogFn // Global log function to use for all commands } func (h *HelmClient) prepare(ctx context.Context) error { // Update all repositories to ensure we have the latest chart information for _, repo := range h.repositories { args := []string{"repo", "update", repo.Name} - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) if err != nil { return fmt.Errorf("helm repo update %s: %w, stderr: %s", repo.Name, err, stderr) } @@ -155,7 +138,7 @@ func (h *HelmClient) AddRepo(ctx context.Context, repo *repo.Entry) error { args = append(args, "--pass-credentials") } - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) if err != nil { return fmt.Errorf("helm repo add: %w, stderr: %s", err, stderr) } @@ -169,7 +152,7 @@ func (h *HelmClient) Latest(ctx context.Context, reponame, chart string) (string // Use helm search repo with JSON output to find the latest version args := []string{"search", "repo", fmt.Sprintf("%s/%s", reponame, chart), "--version", ">0.0.0", "--versions", "--output", "json"} - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) if err != nil { return "", fmt.Errorf("helm search repo: %w, stderr: %s", err, stderr) } @@ -231,7 +214,7 @@ func (h *HelmClient) PullByRef(ctx context.Context, ref string, version string) // Add debug flag to report progress and capture debug logs args = append(args, "--debug") - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) if err != nil { return "", fmt.Errorf("helm pull: %w, stderr: %s", err, stderr) } @@ -252,7 +235,7 @@ func (h *HelmClient) RegistryAuth(ctx context.Context, server, user, pass string // Use helm registry login for authentication args := []string{"registry", "login", server, "--username", user, "--password", pass} - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) if err != nil { return fmt.Errorf("helm registry login: %w, stderr: %s", err, stderr) } @@ -264,7 +247,7 @@ func (h *HelmClient) Push(ctx context.Context, path, dst string) error { // Use helm push to upload the chart args := []string{"push", path, dst} - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) if err != nil { return fmt.Errorf("helm push: %w, stderr: %s", err, stderr) } @@ -279,7 +262,7 @@ func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version s args = append(args, "--version", version) } - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) if err != nil { return nil, fmt.Errorf("helm show chart: %w, stderr: %s", err, stderr) } @@ -295,7 +278,7 @@ func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releas // Use helm list to check if release exists args := []string{"list", "--namespace", namespace, "--filter", fmt.Sprintf("^%s$", releaseName), "--output", "json"} - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) if err != nil { return false, fmt.Errorf("helm list: %w, stderr: %s", err, stderr) } @@ -397,8 +380,14 @@ func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, // NOTE: we don't set client.Atomic = true on install as it makes installation failures difficult to debug // since it will rollback the release. + // Check for log function override + logFn := h.logFn + if opts.LogFn != nil { + logFn = opts.LogFn + } + // Execute helm install command - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, logFn, args...) if err != nil { return "", fmt.Errorf("helm install: %w, stderr: %s", err, stderr) } @@ -487,8 +476,14 @@ func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, // Add debug flag to report progress and capture debug logs args = append(args, "--debug") + // Check for log function override + logFn := h.logFn + if opts.LogFn != nil { + logFn = opts.LogFn + } + // Execute helm upgrade command - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, logFn, args...) if err != nil { return "", fmt.Errorf("helm upgrade: %w, stderr: %s", err, stderr) } @@ -527,8 +522,14 @@ func (h *HelmClient) Uninstall(ctx context.Context, opts UninstallOptions) error args = append(args, "--timeout", timeout.String()) } + // Check for log function override + logFn := h.logFn + if opts.LogFn != nil { + logFn = opts.LogFn + } + // Execute helm uninstall command - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, logFn, args...) if err != nil { return fmt.Errorf("helm uninstall: %w, stderr: %s", err, stderr) } @@ -585,8 +586,14 @@ func (h *HelmClient) Render(ctx context.Context, opts InstallOptions) ([][]byte, // Add debug flag to report progress and capture debug logs args = append(args, "--debug") + // Check for log function override + logFn := h.logFn + if opts.LogFn != nil { + logFn = opts.LogFn + } + // Execute helm template command - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, logFn, args...) if err != nil { return nil, fmt.Errorf("helm template: %w, stderr: %s", err, stderr) } diff --git a/pkg/helm/client_test.go b/pkg/helm/client_test.go index cc8b6bbc12..f2d6390629 100644 --- a/pkg/helm/client_test.go +++ b/pkg/helm/client_test.go @@ -38,6 +38,7 @@ func TestHelmClient_PullByRef(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn []string{"repo", "update", "myrepo"}, ).Return("", "", nil) @@ -45,6 +46,7 @@ func TestHelmClient_PullByRef(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn mock.MatchedBy(func(args []string) bool { return len(args) == 7 && args[0] == "pull" && @@ -61,6 +63,7 @@ func TestHelmClient_PullByRef(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn []string{"show", "chart", "myrepo/mychart", "--version", "1.2.3"}, ).Return(`apiVersion: v2 name: mychart @@ -84,6 +87,7 @@ appVersion: "1.0.0"`, "", nil) m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn mock.MatchedBy(func(args []string) bool { return len(args) == 7 && args[0] == "pull" && @@ -100,6 +104,7 @@ appVersion: "1.0.0"`, "", nil) m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn []string{"show", "chart", "oci://registry.example.com/charts/nginx", "--version", "2.1.0"}, ).Return(`apiVersion: v2 name: nginx @@ -156,6 +161,7 @@ func TestHelmClient_Install(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn []string{"install", "myrelease", "/path/to/chart", "--namespace", "default", "--create-namespace", "--wait", "--wait-for-jobs", "--timeout", "5m0s", "--replace", "--debug"}, ).Return(`Release "myrelease" has been upgraded.`, "", nil) }, @@ -173,6 +179,7 @@ func TestHelmClient_Install(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn mock.MatchedBy(func(args []string) bool { // Check that it contains the expected arguments hasInstall := false @@ -247,6 +254,7 @@ func TestHelmClient_ReleaseExists(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn []string{"list", "--namespace", "default", "--filter", "^myrelease$", "--output", "json"}, ).Return(`[{ "name": "myrelease", @@ -269,6 +277,7 @@ func TestHelmClient_ReleaseExists(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn []string{"list", "--namespace", "default", "--filter", "^myrelease$", "--output", "json"}, ).Return(`[]`, "", nil) }, @@ -317,6 +326,7 @@ func TestHelmClient_GetChartMetadata(t *testing.T) { m.On("ExecuteCommand", mock.Anything, // context mock.Anything, // env + mock.Anything, // LogFn []string{"show", "chart", "/path/to/chart", "--version", "1.0.0"}, ).Return(`apiVersion: v2 name: test-chart @@ -534,7 +544,7 @@ func TestHelmClient_Latest(t *testing.T) { "description": "A test chart" } ]` - m.On("ExecuteCommand", mock.Anything, mock.Anything, + m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). Return(jsonOutput, "", nil) }, @@ -546,7 +556,7 @@ func TestHelmClient_Latest(t *testing.T) { reponame: "myrepo", chart: "nonexistent", setupMock: func(m *MockBinaryExecutor) { - m.On("ExecuteCommand", mock.Anything, mock.Anything, + m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, []string{"search", "repo", "myrepo/nonexistent", "--version", ">0.0.0", "--versions", "--output", "json"}). Return("[]", "", nil) }, @@ -558,7 +568,7 @@ func TestHelmClient_Latest(t *testing.T) { reponame: "myrepo", chart: "mychart", setupMock: func(m *MockBinaryExecutor) { - m.On("ExecuteCommand", mock.Anything, mock.Anything, + m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). Return("", "repo not found", assert.AnError) }, @@ -570,7 +580,7 @@ func TestHelmClient_Latest(t *testing.T) { reponame: "myrepo", chart: "mychart", setupMock: func(m *MockBinaryExecutor) { - m.On("ExecuteCommand", mock.Anything, mock.Anything, + m.On("ExecuteCommand", mock.Anything, mock.Anything, mock.Anything, []string{"search", "repo", "myrepo/mychart", "--version", ">0.0.0", "--versions", "--output", "json"}). Return("invalid json", "", nil) }, diff --git a/tests/integration/util/helm.go b/tests/integration/util/helm.go index 42aa25d87e..1e82a7d3fe 100644 --- a/tests/integration/util/helm.go +++ b/tests/integration/util/helm.go @@ -15,6 +15,7 @@ func HelmClient(t *testing.T, kubeconfig string) helm.Client { hcli, err := helm.NewClient(helm.HelmOptions{ HelmPath: "helm", // use the helm binary in PATH KubernetesEnvSettings: envSettings, + K8sVersion: "v1.26.0", }) if err != nil { t.Fatalf("failed to create helm client: %s", err) diff --git a/web/src/components/wizard/installation/shared/ErrorMessage.test.tsx b/web/src/components/wizard/installation/shared/ErrorMessage.test.tsx new file mode 100644 index 0000000000..801df50791 --- /dev/null +++ b/web/src/components/wizard/installation/shared/ErrorMessage.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '../../../../test/setup.tsx'; +import ErrorMessage from './ErrorMessage.tsx'; + +describe('ErrorMessage', () => { + it('renders short error messages without truncation', () => { + const shortError = 'This is a short error message'; + + renderWithProviders(); + + expect(screen.getByTestId('error-message')).toBeInTheDocument(); + expect(screen.getByText('Installation Error')).toBeInTheDocument(); + expect(screen.getByText(shortError)).toBeInTheDocument(); + expect(screen.queryByTestId('error-toggle')).not.toBeInTheDocument(); + }); + + it('truncates long error messages by default (250 chars)', () => { + const longError = 'A'.repeat(300); // 300 character error message + + renderWithProviders(); + + const errorElement = screen.getByTestId('error-message'); + expect(errorElement).toBeInTheDocument(); + + // Should show truncated version with ellipsis (250 chars default) + const truncatedText = 'A'.repeat(250) + '...'; + expect(screen.getByText(truncatedText)).toBeInTheDocument(); + + // Should show toggle button + expect(screen.getByTestId('error-toggle')).toBeInTheDocument(); + expect(screen.getByText('Show more')).toBeInTheDocument(); + + // Should not show the full error + expect(screen.queryByText(longError)).not.toBeInTheDocument(); + }); + + it('expands to show more content when "Show more" is clicked', () => { + const longError = 'A'.repeat(300); // 300 character error message + + renderWithProviders(); + + // Initially truncated + expect(screen.getByText('A'.repeat(250) + '...')).toBeInTheDocument(); + expect(screen.getByText('Show more')).toBeInTheDocument(); + + // Click to expand + fireEvent.click(screen.getByTestId('error-toggle')); + + // Should show full content (less than 1000 chars) + expect(screen.getByText(longError)).toBeInTheDocument(); + expect(screen.getByText('Show less')).toBeInTheDocument(); + + // Click to collapse + fireEvent.click(screen.getByTestId('error-toggle')); + + // Should be truncated again + expect(screen.getByText('A'.repeat(250) + '...')).toBeInTheDocument(); + expect(screen.getByText('Show more')).toBeInTheDocument(); + }); + + it('truncates even expanded content when it exceeds 1000 characters', () => { + const veryLongError = 'A'.repeat(1500); // 1500 character error message + + renderWithProviders(); + + // Initially truncated to 250 + expect(screen.getByText('A'.repeat(250) + '...')).toBeInTheDocument(); + + // Click to expand + fireEvent.click(screen.getByTestId('error-toggle')); + + // Should be truncated to 1000 chars even when expanded + expect(screen.getByText('A'.repeat(1000) + '...')).toBeInTheDocument(); + expect(screen.getByText('Show less')).toBeInTheDocument(); + }); + + it('respects custom maxLength and expandedMaxLength props', () => { + const longError = 'A'.repeat(100); + + renderWithProviders( + + ); + + // Initially truncated to custom maxLength + expect(screen.getByText('A'.repeat(20) + '...')).toBeInTheDocument(); + + // Click to expand + fireEvent.click(screen.getByTestId('error-toggle')); + + // Should be truncated to custom expandedMaxLength + expect(screen.getByText('A'.repeat(50) + '...')).toBeInTheDocument(); + }); + + it('does not truncate when error is exactly at maxLength', () => { + const exactLengthError = 'A'.repeat(250); // Exactly 250 characters + + renderWithProviders(); + + expect(screen.getByText(exactLengthError)).toBeInTheDocument(); + expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument(); + expect(screen.queryByTestId('error-toggle')).not.toBeInTheDocument(); + }); + + it('handles very long error messages similar to those in bug reports', () => { + const veryLongError = `level=DEBUG msg=Request id=3 url=https://ec-e2e-proxy.testcluster.net/v2/anonymous/ttl.sh/salah/embedded-cluster-operator/blobs/sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\\"\\n"Content-Security-Policy": "frame-ancestors \\'none\\'; default-src \\'none\\'; sandbox","digest":"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131","mediaType":"application/vnd.cnf.helm.chart.content.v1.tar+gzip","digest":"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131","layer":"application/vnd.oci.image.layer.v1.tar+gzip","size":1259}],"layer":[{"mediaType":"application/vnd.cnf.helm.chart.content.v1.tar+gzip","digest":"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131","size":1259}],"digest":"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131","mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":1259}]}`; + + renderWithProviders(); + + const errorElement = screen.getByTestId('error-message'); + expect(errorElement).toBeInTheDocument(); + + // Should be truncated to 250 characters + ellipsis + const truncatedText = veryLongError.substring(0, 250) + '...'; + expect(screen.getByText(truncatedText)).toBeInTheDocument(); + + // Should have expand button + expect(screen.getByTestId('error-toggle')).toBeInTheDocument(); + + // Should not show the full error initially + expect(screen.queryByText(veryLongError)).not.toBeInTheDocument(); + }); + + it('applies proper CSS classes for text wrapping', () => { + const longError = 'A'.repeat(300); + + renderWithProviders(); + + const errorParagraph = screen.getByText(/A+\.{3}/); + expect(errorParagraph).toHaveClass('whitespace-pre-wrap'); + expect(errorParagraph).toHaveClass('break-words'); + }); +}); \ No newline at end of file diff --git a/web/src/components/wizard/installation/shared/ErrorMessage.tsx b/web/src/components/wizard/installation/shared/ErrorMessage.tsx index e98f063369..59b832ae15 100644 --- a/web/src/components/wizard/installation/shared/ErrorMessage.tsx +++ b/web/src/components/wizard/installation/shared/ErrorMessage.tsx @@ -1,24 +1,61 @@ -import React from 'react'; -import { XCircle } from 'lucide-react'; +import React, { useState } from 'react'; +import { XCircle, ChevronDown, ChevronUp } from 'lucide-react'; interface ErrorMessageProps { error: string; + maxLength?: number; + expandedMaxLength?: number; } -const ErrorMessage: React.FC = ({ error }) => ( -
-
-
- -
-
-

Installation Error

-
-

{error}

+const ErrorMessage: React.FC = ({ + error, + maxLength = 250, + expandedMaxLength = 1000 +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const shouldTruncate = error.length > maxLength; + const shouldTruncateExpanded = error.length > expandedMaxLength; + + let displayError: string; + if (!shouldTruncate) { + displayError = error; + } else if (isExpanded) { + displayError = shouldTruncateExpanded + ? error.substring(0, expandedMaxLength) + '...' + : error; + } else { + displayError = error.substring(0, maxLength) + '...'; + } + + return ( +
+
+
+ +
+
+

Installation Error

+
+

{displayError}

+ {shouldTruncate && ( + + )} +
-
-); + ); +}; export default ErrorMessage; diff --git a/web/src/components/wizard/installation/shared/InstallationProgress.tsx b/web/src/components/wizard/installation/shared/InstallationProgress.tsx index e78b1e30e7..cbe58c59e0 100644 --- a/web/src/components/wizard/installation/shared/InstallationProgress.tsx +++ b/web/src/components/wizard/installation/shared/InstallationProgress.tsx @@ -14,6 +14,10 @@ const InstallationProgress: React.FC = ({ themeColor, status }) => { + const truncateMessage = (message: string, maxLength: number = 250) => { + return message.length > maxLength ? message.substring(0, maxLength) + '...' : message; + }; + return (
@@ -26,7 +30,7 @@ const InstallationProgress: React.FC = ({ />

- {currentMessage || 'Preparing installation...'} + {currentMessage ? truncateMessage(currentMessage) : 'Preparing installation...'}

); diff --git a/web/src/components/wizard/installation/shared/LogViewer.test.tsx b/web/src/components/wizard/installation/shared/LogViewer.test.tsx index 6653a490c0..1d7d80b391 100644 --- a/web/src/components/wizard/installation/shared/LogViewer.test.tsx +++ b/web/src/components/wizard/installation/shared/LogViewer.test.tsx @@ -264,4 +264,49 @@ describe('LogViewer', () => { expect(mockScrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }); }); }); + + it('handles extremely long log lines without causing horizontal overflow', () => { + const longLogLines = [ + 'Short log message', + // Very long JSON log line similar to what was seen in the bug report + 'level=DEBUG msg=Request id=3 url=https://ec-e2e-proxy.testcluster.net/v2/anonymous/ttl.sh/salah/embedded-cluster-operator/blobs/sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\\\\\"\\n\"Content-Security-Policy\": \"frame-ancestors \\\'none\\\'; default-src \\\'none\\\'; sandbox\",\"digest\":\"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\",\"mediaType\":\"application/vnd.cnf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\",\"layer\":\"application/vnd.oci.image.layer.v1.tar+gzip\",\"size\":1259}],\"layer\":[{\"mediaType\":\"application/vnd.cnf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\",\"size\":1259}],\"digest\":\"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\",\"mediaType\":\"application/vnd.oci.image.layer.v1.tar+gzip\",\"size\":1259}]}', + // Another very long line with repeated text + 'Authorization: Bearer ' + 'a'.repeat(500) + ' User-Agent: Helm/3.18.0', + 'Regular log message after long lines' + ]; + + renderWithProviders( + + ); + + const logContainer = screen.getByTestId('log-viewer-content'); + + // Verify the container has proper overflow classes + expect(logContainer).toHaveClass('overflow-y-auto'); + expect(logContainer).toHaveClass('overflow-x-auto'); + + // Verify all log lines are rendered + longLogLines.forEach(log => { + expect(screen.getByText(log)).toBeInTheDocument(); + }); + + // Get all log line divs and verify they have break-all class + const logElements = logContainer.querySelectorAll('div'); + const logLineDivs = Array.from(logElements).filter(div => + div.textContent && longLogLines.some(log => div.textContent === log) + ); + + logLineDivs.forEach(logDiv => { + expect(logDiv).toHaveClass('break-all'); + expect(logDiv).toHaveClass('whitespace-pre-wrap'); + }); + + // Test that the component doesn't crash with very long content + expect(logContainer).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/web/src/components/wizard/installation/shared/LogViewer.tsx b/web/src/components/wizard/installation/shared/LogViewer.tsx index 510fcaea90..862d1e2462 100644 --- a/web/src/components/wizard/installation/shared/LogViewer.tsx +++ b/web/src/components/wizard/installation/shared/LogViewer.tsx @@ -52,11 +52,11 @@ const LogViewer: React.FC = ({
{logs.map((log, index) => ( -
+
{log}
))} From fd1e5bbe22870e5a950e0ec8034576bcb9e0a765 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Wed, 3 Sep 2025 13:47:03 -0700 Subject: [PATCH 07/34] fix web lint --- .../components/wizard/installation/shared/LogViewer.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/wizard/installation/shared/LogViewer.test.tsx b/web/src/components/wizard/installation/shared/LogViewer.test.tsx index 1d7d80b391..a2e2db2b71 100644 --- a/web/src/components/wizard/installation/shared/LogViewer.test.tsx +++ b/web/src/components/wizard/installation/shared/LogViewer.test.tsx @@ -269,7 +269,7 @@ describe('LogViewer', () => { const longLogLines = [ 'Short log message', // Very long JSON log line similar to what was seen in the bug report - 'level=DEBUG msg=Request id=3 url=https://ec-e2e-proxy.testcluster.net/v2/anonymous/ttl.sh/salah/embedded-cluster-operator/blobs/sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\\\\\"\\n\"Content-Security-Policy\": \"frame-ancestors \\\'none\\\'; default-src \\\'none\\\'; sandbox\",\"digest\":\"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\",\"mediaType\":\"application/vnd.cnf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\",\"layer\":\"application/vnd.oci.image.layer.v1.tar+gzip\",\"size\":1259}],\"layer\":[{\"mediaType\":\"application/vnd.cnf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\",\"size\":1259}],\"digest\":\"sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131\",\"mediaType\":\"application/vnd.oci.image.layer.v1.tar+gzip\",\"size\":1259}]}', + `level=DEBUG msg=Request id=3 url=https://ec-e2e-proxy.testcluster.net/v2/anonymous/ttl.sh/salah/embedded-cluster-operator/blobs/sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131 Content-Security-Policy: frame-ancestors none; default-src none; sandbox digest:sha256:4b2ac16cacd8d47216406c3d0061666949203030c2c74ccst1756913131 mediaType:application/vnd.cnf.helm.chart.content.v1.tar+gzip layer:application/vnd.oci.image.layer.v1.tar+gzip size:1259 very long log line that simulates the overflow issue without complex JSON escaping`, // Another very long line with repeated text 'Authorization: Bearer ' + 'a'.repeat(500) + ' User-Agent: Helm/3.18.0', 'Regular log message after long lines' From 274df3f137e5820482d20e3dfd1d814029f84aa5 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Wed, 3 Sep 2025 13:48:39 -0700 Subject: [PATCH 08/34] remove todo --- api/internal/managers/app/release/template_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/internal/managers/app/release/template_test.go b/api/internal/managers/app/release/template_test.go index 35f2b29231..c9e2f629c5 100644 --- a/api/internal/managers/app/release/template_test.go +++ b/api/internal/managers/app/release/template_test.go @@ -334,8 +334,8 @@ spec: // Create real helm client config := createTestConfig() hcli, err := helm.NewClient(helm.HelmOptions{ - HelmPath: "helm", // use the helm binary in PATH - K8sVersion: "v1.33.0", // TODO NOW: check that is gets used + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: "v1.33.0", }) require.NoError(t, err) manager, err := NewAppReleaseManager( From cdbf5ce1ecce365c7effde45d6751960c01b8f24 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Wed, 3 Sep 2025 14:19:27 -0700 Subject: [PATCH 09/34] fix tests and logs --- .github/workflows/ci.yaml | 4 ++ operator/pkg/cli/upgrade_job.go | 3 -- pkg/addons/adminconsole/install.go | 1 + pkg/addons/adminconsole/upgrade.go | 1 + pkg/addons/embeddedclusteroperator/install.go | 1 + pkg/addons/embeddedclusteroperator/upgrade.go | 1 + pkg/addons/openebs/install.go | 1 + pkg/addons/openebs/upgrade.go | 1 + pkg/addons/registry/install.go | 1 + pkg/addons/registry/upgrade.go | 1 + pkg/addons/seaweedfs/install.go | 1 + pkg/addons/seaweedfs/upgrade.go | 1 + pkg/addons/velero/install.go | 1 + pkg/addons/velero/upgrade.go | 1 + pkg/helm/binary_executor_test.go | 4 +- pkg/helm/client.go | 51 +++++-------------- 16 files changed, 30 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f770bb599a..a3a7b12b8d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -163,6 +163,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + fetch-depth: 0 # necessary for getting the last tag - name: Setup go uses: actions/setup-go@v5 with: @@ -186,6 +188,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 + with: + fetch-depth: 0 # necessary for getting the last tag - name: Setup go uses: actions/setup-go@v5 with: diff --git a/operator/pkg/cli/upgrade_job.go b/operator/pkg/cli/upgrade_job.go index 021d1a44b2..ec44b98609 100644 --- a/operator/pkg/cli/upgrade_job.go +++ b/operator/pkg/cli/upgrade_job.go @@ -63,9 +63,6 @@ func UpgradeJobCmd() *cobra.Command { HelmPath: "helm", // use the helm binary in PATH K8sVersion: versions.K0sVersion, AirgapPath: airgapChartsPath, - LogFn: func(format string, v ...interface{}) { - slog.Info(fmt.Sprintf(format, v...), "component", "helm") - }, }) if err != nil { return fmt.Errorf("failed to create helm client: %w", err) diff --git a/pkg/addons/adminconsole/install.go b/pkg/addons/adminconsole/install.go index 486e0b80ce..bbf0348a05 100644 --- a/pkg/addons/adminconsole/install.go +++ b/pkg/addons/adminconsole/install.go @@ -57,6 +57,7 @@ func (a *AdminConsole) Install( Values: values, Namespace: a.Namespace(), Labels: getBackupLabels(), + LogFn: helm.LogFn(logf), } if a.DryRun { diff --git a/pkg/addons/adminconsole/upgrade.go b/pkg/addons/adminconsole/upgrade.go index ffd85b0a86..45b095d595 100644 --- a/pkg/addons/adminconsole/upgrade.go +++ b/pkg/addons/adminconsole/upgrade.go @@ -45,6 +45,7 @@ func (a *AdminConsole) Upgrade( Namespace: a.Namespace(), Labels: getBackupLabels(), Force: false, + LogFn: helm.LogFn(logf), }) if err != nil { return errors.Wrap(err, "helm upgrade") diff --git a/pkg/addons/embeddedclusteroperator/install.go b/pkg/addons/embeddedclusteroperator/install.go index 5e0e1f5909..394526f8c9 100644 --- a/pkg/addons/embeddedclusteroperator/install.go +++ b/pkg/addons/embeddedclusteroperator/install.go @@ -28,6 +28,7 @@ func (e *EmbeddedClusterOperator) Install( Values: values, Namespace: e.Namespace(), Labels: getBackupLabels(), + LogFn: helm.LogFn(logf), } if e.DryRun { diff --git a/pkg/addons/embeddedclusteroperator/upgrade.go b/pkg/addons/embeddedclusteroperator/upgrade.go index cbc2668076..7ec902295d 100644 --- a/pkg/addons/embeddedclusteroperator/upgrade.go +++ b/pkg/addons/embeddedclusteroperator/upgrade.go @@ -42,6 +42,7 @@ func (e *EmbeddedClusterOperator) Upgrade( Namespace: e.Namespace(), Labels: getBackupLabels(), Force: false, + LogFn: helm.LogFn(logf), }) if err != nil { return errors.Wrap(err, "helm upgrade") diff --git a/pkg/addons/openebs/install.go b/pkg/addons/openebs/install.go index d5752ba942..59db665e5d 100644 --- a/pkg/addons/openebs/install.go +++ b/pkg/addons/openebs/install.go @@ -27,6 +27,7 @@ func (o *OpenEBS) Install( ChartVersion: Metadata.Version, Values: values, Namespace: o.Namespace(), + LogFn: helm.LogFn(logf), }) if err != nil { return errors.Wrap(err, "helm install") diff --git a/pkg/addons/openebs/upgrade.go b/pkg/addons/openebs/upgrade.go index d891b95da5..db9f88e9a6 100644 --- a/pkg/addons/openebs/upgrade.go +++ b/pkg/addons/openebs/upgrade.go @@ -41,6 +41,7 @@ func (o *OpenEBS) Upgrade( Values: values, Namespace: o.Namespace(), Force: false, + LogFn: helm.LogFn(logf), }) if err != nil { return errors.Wrap(err, "helm upgrade") diff --git a/pkg/addons/registry/install.go b/pkg/addons/registry/install.go index d27030517f..0268c1f2ec 100644 --- a/pkg/addons/registry/install.go +++ b/pkg/addons/registry/install.go @@ -44,6 +44,7 @@ func (r *Registry) Install( Values: values, Namespace: r.Namespace(), Labels: getBackupLabels(), + LogFn: helm.LogFn(logf), }) if err != nil { return errors.Wrap(err, "helm install") diff --git a/pkg/addons/registry/upgrade.go b/pkg/addons/registry/upgrade.go index 6de7be4884..ad4f712089 100644 --- a/pkg/addons/registry/upgrade.go +++ b/pkg/addons/registry/upgrade.go @@ -57,6 +57,7 @@ func (r *Registry) Upgrade( Namespace: r.Namespace(), Labels: getBackupLabels(), Force: false, + LogFn: helm.LogFn(logf), }) if err != nil { return errors.Wrap(err, "helm upgrade") diff --git a/pkg/addons/seaweedfs/install.go b/pkg/addons/seaweedfs/install.go index 79dd480287..d8b1037209 100644 --- a/pkg/addons/seaweedfs/install.go +++ b/pkg/addons/seaweedfs/install.go @@ -44,6 +44,7 @@ func (s *SeaweedFS) Install( Values: values, Namespace: s.Namespace(), Labels: getBackupLabels(), + LogFn: helm.LogFn(logf), }) if err != nil { return errors.Wrap(err, "helm install") diff --git a/pkg/addons/seaweedfs/upgrade.go b/pkg/addons/seaweedfs/upgrade.go index 52e4c9b685..ea4370c5ed 100644 --- a/pkg/addons/seaweedfs/upgrade.go +++ b/pkg/addons/seaweedfs/upgrade.go @@ -43,6 +43,7 @@ func (s *SeaweedFS) Upgrade( Namespace: s.Namespace(), Labels: getBackupLabels(), Force: false, + LogFn: helm.LogFn(logf), }) if err != nil { return errors.Wrap(err, "helm upgrade") diff --git a/pkg/addons/velero/install.go b/pkg/addons/velero/install.go index dce9b5f2ea..51206b7dd8 100644 --- a/pkg/addons/velero/install.go +++ b/pkg/addons/velero/install.go @@ -35,6 +35,7 @@ func (v *Velero) Install( ChartVersion: Metadata.Version, Values: values, Namespace: v.Namespace(), + LogFn: helm.LogFn(logf), } if v.DryRun { diff --git a/pkg/addons/velero/upgrade.go b/pkg/addons/velero/upgrade.go index 00560814ca..e73ed739fe 100644 --- a/pkg/addons/velero/upgrade.go +++ b/pkg/addons/velero/upgrade.go @@ -41,6 +41,7 @@ func (v *Velero) Upgrade( Values: values, Namespace: v.Namespace(), Force: false, + LogFn: helm.LogFn(logf), }) if err != nil { return errors.Wrap(err, "helm upgrade") diff --git a/pkg/helm/binary_executor_test.go b/pkg/helm/binary_executor_test.go index ba6f4b03ab..2893ff981e 100644 --- a/pkg/helm/binary_executor_test.go +++ b/pkg/helm/binary_executor_test.go @@ -118,7 +118,7 @@ func Test_binaryExecutor_ExecuteCommand_WithLogging(t *testing.T) { func Test_logWriter_Write(t *testing.T) { var loggedMessages []string logFn := func(format string, v ...any) { - loggedMessages = append(loggedMessages, format) + loggedMessages = append(loggedMessages, fmt.Sprintf(format, v...)) } writer := &logWriter{logFn: logFn} @@ -128,7 +128,7 @@ func Test_logWriter_Write(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 28, n) assert.Len(t, loggedMessages, 1) - assert.Equal(t, "%s", loggedMessages[0]) + assert.Equal(t, "install.go:225: test message", loggedMessages[0]) // Test writing data that doesn't match .go file pattern (should be filtered out) loggedMessages = nil diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 4301899068..202b940950 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -44,7 +44,6 @@ func newClient(opts HelmOptions) (*HelmClient, error) { kubernetesEnvSettings: opts.KubernetesEnvSettings, airgapPath: opts.AirgapPath, repositories: []*repo.Entry{}, - logFn: opts.LogFn, }, nil } @@ -53,7 +52,6 @@ type HelmOptions struct { KubernetesEnvSettings *helmcli.EnvSettings K8sVersion string AirgapPath string - LogFn LogFn // Global log function to use for all commands } type LogFn func(format string, args ...interface{}) @@ -97,14 +95,13 @@ type HelmClient struct { kubernetesEnvSettings *helmcli.EnvSettings // Kubernetes environment settings airgapPath string // Airgap path where charts are stored repositories []*repo.Entry // Repository entries for helm repo commands - logFn LogFn // Global log function to use for all commands } func (h *HelmClient) prepare(ctx context.Context) error { // Update all repositories to ensure we have the latest chart information for _, repo := range h.repositories { args := []string{"repo", "update", repo.Name} - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { return fmt.Errorf("helm repo update %s: %w, stderr: %s", repo.Name, err, stderr) } @@ -138,7 +135,7 @@ func (h *HelmClient) AddRepo(ctx context.Context, repo *repo.Entry) error { args = append(args, "--pass-credentials") } - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { return fmt.Errorf("helm repo add: %w, stderr: %s", err, stderr) } @@ -152,7 +149,7 @@ func (h *HelmClient) Latest(ctx context.Context, reponame, chart string) (string // Use helm search repo with JSON output to find the latest version args := []string{"search", "repo", fmt.Sprintf("%s/%s", reponame, chart), "--version", ">0.0.0", "--versions", "--output", "json"} - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { return "", fmt.Errorf("helm search repo: %w, stderr: %s", err, stderr) } @@ -214,7 +211,7 @@ func (h *HelmClient) PullByRef(ctx context.Context, ref string, version string) // Add debug flag to report progress and capture debug logs args = append(args, "--debug") - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { return "", fmt.Errorf("helm pull: %w, stderr: %s", err, stderr) } @@ -235,7 +232,7 @@ func (h *HelmClient) RegistryAuth(ctx context.Context, server, user, pass string // Use helm registry login for authentication args := []string{"registry", "login", server, "--username", user, "--password", pass} - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { return fmt.Errorf("helm registry login: %w, stderr: %s", err, stderr) } @@ -247,7 +244,7 @@ func (h *HelmClient) Push(ctx context.Context, path, dst string) error { // Use helm push to upload the chart args := []string{"push", path, dst} - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { return fmt.Errorf("helm push: %w, stderr: %s", err, stderr) } @@ -262,7 +259,7 @@ func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version s args = append(args, "--version", version) } - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { return nil, fmt.Errorf("helm show chart: %w, stderr: %s", err, stderr) } @@ -278,7 +275,7 @@ func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releas // Use helm list to check if release exists args := []string{"list", "--namespace", namespace, "--filter", fmt.Sprintf("^%s$", releaseName), "--output", "json"} - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, h.logFn, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { return false, fmt.Errorf("helm list: %w, stderr: %s", err, stderr) } @@ -380,14 +377,8 @@ func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, // NOTE: we don't set client.Atomic = true on install as it makes installation failures difficult to debug // since it will rollback the release. - // Check for log function override - logFn := h.logFn - if opts.LogFn != nil { - logFn = opts.LogFn - } - // Execute helm install command - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, logFn, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { return "", fmt.Errorf("helm install: %w, stderr: %s", err, stderr) } @@ -476,14 +467,8 @@ func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, // Add debug flag to report progress and capture debug logs args = append(args, "--debug") - // Check for log function override - logFn := h.logFn - if opts.LogFn != nil { - logFn = opts.LogFn - } - // Execute helm upgrade command - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, logFn, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { return "", fmt.Errorf("helm upgrade: %w, stderr: %s", err, stderr) } @@ -522,14 +507,8 @@ func (h *HelmClient) Uninstall(ctx context.Context, opts UninstallOptions) error args = append(args, "--timeout", timeout.String()) } - // Check for log function override - logFn := h.logFn - if opts.LogFn != nil { - logFn = opts.LogFn - } - // Execute helm uninstall command - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, logFn, args...) + _, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { return fmt.Errorf("helm uninstall: %w, stderr: %s", err, stderr) } @@ -586,14 +565,8 @@ func (h *HelmClient) Render(ctx context.Context, opts InstallOptions) ([][]byte, // Add debug flag to report progress and capture debug logs args = append(args, "--debug") - // Check for log function override - logFn := h.logFn - if opts.LogFn != nil { - logFn = opts.LogFn - } - // Execute helm template command - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, logFn, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { return nil, fmt.Errorf("helm template: %w, stderr: %s", err, stderr) } From 659ab3a66c6d5aab657d8f2b4aa09239909cc0e7 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Wed, 3 Sep 2025 14:22:03 -0700 Subject: [PATCH 10/34] move env flags --- cmd/installer/cli/install.go | 21 +-------------------- pkg/helm/client.go | 26 +++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/cmd/installer/cli/install.go b/cmd/installer/cli/install.go index 924c4e2cd1..454d7937eb 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -316,27 +316,8 @@ func newKubernetesInstallFlags(flags *InstallCmdFlags, enableV3 bool) *pflag.Fla } func addKubernetesCLIFlags(flagSet *pflag.FlagSet, flags *InstallCmdFlags) { - // From helm - // https://github.com/helm/helm/blob/v3.18.3/pkg/cli/environment.go#L145-L163 - s := helmcli.New() - - flagSet.StringVar(&s.KubeConfig, "kubeconfig", "", "Path to the kubeconfig file") - flagSet.StringVar(&s.KubeContext, "kube-context", s.KubeContext, "Name of the kubeconfig context to use") - flagSet.StringVar(&s.KubeToken, "kube-token", s.KubeToken, "Bearer token used for authentication") - flagSet.StringVar(&s.KubeAsUser, "kube-as-user", s.KubeAsUser, "Username to impersonate for the operation") - flagSet.StringArrayVar(&s.KubeAsGroups, "kube-as-group", s.KubeAsGroups, "Group to impersonate for the operation, this flag can be repeated to specify multiple groups.") - flagSet.StringVar(&s.KubeAPIServer, "kube-apiserver", s.KubeAPIServer, "The address and the port for the Kubernetes API server") - flagSet.StringVar(&s.KubeCaFile, "kube-ca-file", s.KubeCaFile, "The certificate authority file for the Kubernetes API server connection") - flagSet.StringVar(&s.KubeTLSServerName, "kube-tls-server-name", s.KubeTLSServerName, "Server name to use for Kubernetes API server certificate validation. If it is not provided, the hostname used to contact the server is used") - // flagSet.BoolVar(&s.Debug, "helm-debug", s.Debug, "enable verbose output") - flagSet.BoolVar(&s.KubeInsecureSkipTLSVerify, "kube-insecure-skip-tls-verify", s.KubeInsecureSkipTLSVerify, "If true, the Kubernetes API server's certificate will not be checked for validity. This will make your HTTPS connections insecure") - // flagSet.StringVar(&s.RegistryConfig, "helm-registry-config", s.RegistryConfig, "Path to the Helm registry config file") - // flagSet.StringVar(&s.RepositoryConfig, "helm-repository-config", s.RepositoryConfig, "Path to the file containing Helm repository names and URLs") - // flagSet.StringVar(&s.RepositoryCache, "helm-repository-cache", s.RepositoryCache, "Path to the directory containing cached Helm repository indexes") - flagSet.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "Kubernetes API client-side default throttling limit") - flagSet.Float32Var(&s.QPS, "qps", s.QPS, "Queries per second used when communicating with the Kubernetes API, not including bursting") - + helm.AddKubernetesCLIFlags(flagSet, s) flags.kubernetesEnvSettings = s } diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 202b940950..b729910ab1 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -11,6 +11,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/sirupsen/logrus" + "github.com/spf13/pflag" "gopkg.in/yaml.v3" "helm.sh/helm/v3/pkg/chart" helmcli "helm.sh/helm/v3/pkg/cli" @@ -585,7 +586,7 @@ func (h *HelmClient) addKubernetesEnvArgs(args []string) []string { } // Add all helm CLI flags from kubernetesEnvSettings - // Based on addKubernetesCLIFlags function + // Based on addKubernetesCLIFlags function below if h.kubernetesEnvSettings.KubeConfig != "" { args = append(args, "--kubeconfig", h.kubernetesEnvSettings.KubeConfig) } @@ -623,6 +624,29 @@ func (h *HelmClient) addKubernetesEnvArgs(args []string) []string { return args } +// AddKubernetesCLIFlags adds Kubernetes-related CLI flags to a pflag.FlagSet +// This function is used to configure Kubernetes environment settings +func AddKubernetesCLIFlags(flagSet *pflag.FlagSet, kubernetesEnvSettings *helmcli.EnvSettings) { + // From helm + // https://github.com/helm/helm/blob/v3.18.3/pkg/cli/environment.go#L145-L163 + + flagSet.StringVar(&kubernetesEnvSettings.KubeConfig, "kubeconfig", "", "Path to the kubeconfig file") + flagSet.StringVar(&kubernetesEnvSettings.KubeContext, "kube-context", kubernetesEnvSettings.KubeContext, "Name of the kubeconfig context to use") + flagSet.StringVar(&kubernetesEnvSettings.KubeToken, "kube-token", kubernetesEnvSettings.KubeToken, "Bearer token used for authentication") + flagSet.StringVar(&kubernetesEnvSettings.KubeAsUser, "kube-as-user", kubernetesEnvSettings.KubeAsUser, "Username to impersonate for the operation") + flagSet.StringArrayVar(&kubernetesEnvSettings.KubeAsGroups, "kube-as-group", kubernetesEnvSettings.KubeAsGroups, "Group to impersonate for the operation, this flag can be repeated to specify multiple groups.") + flagSet.StringVar(&kubernetesEnvSettings.KubeAPIServer, "kube-apiserver", kubernetesEnvSettings.KubeAPIServer, "The address and the port for the Kubernetes API server") + flagSet.StringVar(&kubernetesEnvSettings.KubeCaFile, "kube-ca-file", kubernetesEnvSettings.KubeCaFile, "The certificate authority file for the Kubernetes API server connection") + flagSet.StringVar(&kubernetesEnvSettings.KubeTLSServerName, "kube-tls-server-name", kubernetesEnvSettings.KubeTLSServerName, "Server name to use for Kubernetes API server certificate validation. If it is not provided, the hostname used to contact the server is used") + // flagSet.BoolVar(&kubernetesEnvSettings.Debug, "helm-debug", kubernetesEnvSettings.Debug, "enable verbose output") + flagSet.BoolVar(&kubernetesEnvSettings.KubeInsecureSkipTLSVerify, "kube-insecure-skip-tls-verify", kubernetesEnvSettings.KubeInsecureSkipTLSVerify, "If true, the Kubernetes API server's certificate will not be checked for validity. This will make your HTTPS connections insecure") + // flagSet.StringVar(&kubernetesEnvSettings.RegistryConfig, "helm-registry-config", kubernetesEnvSettings.RegistryConfig, "Path to the Helm registry config file") + // flagSet.StringVar(&kubernetesEnvSettings.RepositoryConfig, "helm-repository-config", kubernetesEnvSettings.RepositoryConfig, "Path to the file containing Helm repository names and URLs") + // flagSet.StringVar(&kubernetesEnvSettings.RepositoryCache, "helm-repository-cache", kubernetesEnvSettings.RepositoryCache, "Path to the directory containing cached Helm repository indexes") + flagSet.IntVar(&kubernetesEnvSettings.BurstLimit, "burst-limit", kubernetesEnvSettings.BurstLimit, "Kubernetes API client-side default throttling limit") + flagSet.Float32Var(&kubernetesEnvSettings.QPS, "qps", kubernetesEnvSettings.QPS, "Queries per second used when communicating with the Kubernetes API, not including bursting") +} + func cleanUpGenericMap(m map[string]interface{}) (map[string]interface{}, error) { // we must first use yaml marshal to convert the map[interface{}]interface{} to a []byte // otherwise we will get an error "unsupported type: map[interface {}]interface {}" From c0296f9945aebfe00eb2f4ec2b38c5bec5f4c9bf Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Wed, 3 Sep 2025 14:31:04 -0700 Subject: [PATCH 11/34] add helm log prefix --- pkg/helm/binary_executor.go | 2 +- pkg/helm/binary_executor_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/helm/binary_executor.go b/pkg/helm/binary_executor.go index e73b2468ac..d74800ae2c 100644 --- a/pkg/helm/binary_executor.go +++ b/pkg/helm/binary_executor.go @@ -54,7 +54,7 @@ func (lw *logWriter) Write(p []byte) (n int, err error) { if lw.logFn != nil && len(p) > 0 { line := strings.TrimSpace(string(p)) if line != "" && goFilePattern.MatchString(line) { - lw.logFn("%s", line) + lw.logFn("helm: %s", line) } } return len(p), nil diff --git a/pkg/helm/binary_executor_test.go b/pkg/helm/binary_executor_test.go index 2893ff981e..688c28e81a 100644 --- a/pkg/helm/binary_executor_test.go +++ b/pkg/helm/binary_executor_test.go @@ -84,7 +84,7 @@ func Test_binaryExecutor_ExecuteCommand_WithLogging(t *testing.T) { wantErr: false, expectedStdout: "stdout message\n", expectedStderr: "install.go:225: debug message\n", - expectedLogs: []string{"install.go:225: debug message"}, // Go file pattern should be logged + expectedLogs: []string{"helm: install.go:225: debug message"}, // Go file pattern should be logged with helm prefix }, } @@ -128,7 +128,7 @@ func Test_logWriter_Write(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 28, n) assert.Len(t, loggedMessages, 1) - assert.Equal(t, "install.go:225: test message", loggedMessages[0]) + assert.Equal(t, "helm: install.go:225: test message", loggedMessages[0]) // Test writing data that doesn't match .go file pattern (should be filtered out) loggedMessages = nil From e6bdbe1ec606ec0b8ad69dc6fa646e64712a89e2 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Wed, 3 Sep 2025 20:35:51 -0700 Subject: [PATCH 12/34] try fixing integration test --- pkg/helm/client.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index b729910ab1..d0016ebb79 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -276,6 +276,9 @@ func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releas // Use helm list to check if release exists args := []string{"list", "--namespace", namespace, "--filter", fmt.Sprintf("^%s$", releaseName), "--output", "json"} + // Add kubeconfig and context if available + args = h.addKubernetesEnvArgs(args) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { return false, fmt.Errorf("helm list: %w, stderr: %s", err, stderr) From 23fa0258019faef6b96fc4a74e6ec8bfc69bb254 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 07:53:50 -0700 Subject: [PATCH 13/34] return context error --- pkg/helpers/command.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/helpers/command.go b/pkg/helpers/command.go index 8d76079b3d..5e86ba4a73 100644 --- a/pkg/helpers/command.go +++ b/pkg/helpers/command.go @@ -43,6 +43,11 @@ func (h *Helpers) RunCommandWithOptions(opts RunCommandOptions, bin string, args logrus.Debugf("failed to run command:") logrus.Debugf("stdout: %s", stdout.String()) logrus.Debugf("stderr: %s", stderr.String()) + + // Check if it's a context error and return it instead + if ctx.Err() != nil { + return ctx.Err() + } if stderr.String() != "" { return fmt.Errorf("%w: %s", err, stderr.String()) } From 1da7636b5b87ab361f5264dd32334f8315c812fe Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 08:41:35 -0700 Subject: [PATCH 14/34] one more try --- pkg/helm/client.go | 1 + pkg/helm/client_test.go | 467 +++++++++++++++++++-- tests/integration/kind/registry/ha_test.go | 2 +- 3 files changed, 437 insertions(+), 33 deletions(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index d0016ebb79..618527db61 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -534,6 +534,7 @@ func (h *HelmClient) Render(ctx context.Context, opts InstallOptions) ([][]byte, // Add namespace if opts.Namespace != "" { args = append(args, "--namespace", opts.Namespace) + args = append(args, "--create-namespace") } // Add labels if provided diff --git a/pkg/helm/client_test.go b/pkg/helm/client_test.go index f2d6390629..c267df2b76 100644 --- a/pkg/helm/client_test.go +++ b/pkg/helm/client_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + helmcli "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/repo" k8syaml "sigs.k8s.io/yaml" ) @@ -150,10 +151,11 @@ appVersion: "1.25.0"`, "", nil) func TestHelmClient_Install(t *testing.T) { tests := []struct { - name string - setupMock func(*MockBinaryExecutor) - opts InstallOptions - wantErr bool + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts InstallOptions + wantErr bool }{ { name: "successful install", @@ -165,6 +167,7 @@ func TestHelmClient_Install(t *testing.T) { []string{"install", "myrelease", "/path/to/chart", "--namespace", "default", "--create-namespace", "--wait", "--wait-for-jobs", "--timeout", "5m0s", "--replace", "--debug"}, ).Return(`Release "myrelease" has been upgraded.`, "", nil) }, + kubernetesEnvSettings: nil, // No kubeconfig settings opts: InstallOptions{ ReleaseName: "myrelease", ChartPath: "/path/to/chart", @@ -181,21 +184,13 @@ func TestHelmClient_Install(t *testing.T) { mock.Anything, // env mock.Anything, // LogFn mock.MatchedBy(func(args []string) bool { - // Check that it contains the expected arguments - hasInstall := false - hasValues := false - for i, arg := range args { - if arg == "install" && i == 0 { - hasInstall = true - } - if arg == "--values" && i < len(args)-1 { - hasValues = true - } - } - return hasInstall && hasValues + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "install") && + strings.Contains(argsStr, "--values") }), ).Return(`Release "myrelease" has been installed.`, "", nil) }, + kubernetesEnvSettings: nil, // No kubeconfig settings opts: InstallOptions{ ReleaseName: "myrelease", ChartPath: "/path/to/chart", @@ -207,6 +202,53 @@ func TestHelmClient_Install(t *testing.T) { }, wantErr: false, }, + + { + name: "install with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "install") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`Release "myrelease" has been installed.`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, } for _, tt := range tests { @@ -220,9 +262,10 @@ func TestHelmClient_Install(t *testing.T) { defer os.RemoveAll(tmpdir) client := &HelmClient{ - helmPath: "/usr/local/bin/helm", - executor: mockExec, - tmpdir: tmpdir, + helmPath: "/usr/local/bin/helm", + executor: mockExec, + tmpdir: tmpdir, + kubernetesEnvSettings: tt.kubernetesEnvSettings, } stdout, err := client.Install(t.Context(), tt.opts) @@ -241,12 +284,13 @@ func TestHelmClient_Install(t *testing.T) { func TestHelmClient_ReleaseExists(t *testing.T) { tests := []struct { - name string - setupMock func(*MockBinaryExecutor) - namespace string - releaseName string - want bool - wantErr bool + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + namespace string + releaseName string + want bool + wantErr bool }{ { name: "release exists", @@ -255,7 +299,14 @@ func TestHelmClient_ReleaseExists(t *testing.T) { mock.Anything, // context mock.Anything, // env mock.Anything, // LogFn - []string{"list", "--namespace", "default", "--filter", "^myrelease$", "--output", "json"}, + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "list") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--filter ^myrelease$") && + strings.Contains(argsStr, "--output json") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") + }), ).Return(`[{ "name": "myrelease", "namespace": "default", @@ -266,6 +317,9 @@ func TestHelmClient_ReleaseExists(t *testing.T) { "app_version": "1.0.0" }]`, "", nil) }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + }, namespace: "default", releaseName: "myrelease", want: true, @@ -281,10 +335,11 @@ func TestHelmClient_ReleaseExists(t *testing.T) { []string{"list", "--namespace", "default", "--filter", "^myrelease$", "--output", "json"}, ).Return(`[]`, "", nil) }, - namespace: "default", - releaseName: "myrelease", - want: false, - wantErr: false, + kubernetesEnvSettings: nil, // No kubeconfig settings + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: false, }, } @@ -294,8 +349,9 @@ func TestHelmClient_ReleaseExists(t *testing.T) { tt.setupMock(mockExec) client := &HelmClient{ - helmPath: "/usr/local/bin/helm", - executor: mockExec, + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, } exists, err := client.ReleaseExists(t.Context(), tt.namespace, tt.releaseName) @@ -611,3 +667,350 @@ func TestHelmClient_Latest(t *testing.T) { }) } } + +func TestHelmClient_Upgrade(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts UpgradeOptions + wantErr bool + }{ + { + name: "successful upgrade", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"upgrade", "myrelease", "/path/to/chart", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "5m0s", "--atomic", "--debug"}, + ).Return(`Release "myrelease" has been upgraded.`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: UpgradeOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, + { + name: "upgrade with values", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "upgrade") && + strings.Contains(argsStr, "--values") + }), + ).Return(`Release "myrelease" has been upgraded.`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: UpgradeOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + Values: map[string]interface{}{ + "key": "value", + }, + }, + wantErr: false, + }, + { + name: "upgrade with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "upgrade") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`Release "myrelease" has been upgraded.`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + opts: UpgradeOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + // Create temporary directory for the test + tmpdir, err := os.MkdirTemp("", "helm-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpdir) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + tmpdir: tmpdir, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err = client.Upgrade(t.Context(), tt.opts) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_Uninstall(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts UninstallOptions + wantErr bool + }{ + { + name: "successful uninstall", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"uninstall", "myrelease", "--namespace", "default", "--debug"}, + ).Return(`release "myrelease" uninstalled`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: UninstallOptions{ + ReleaseName: "myrelease", + Namespace: "default", + }, + wantErr: false, + }, + { + name: "uninstall with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "uninstall") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") + }), + ).Return(`release "myrelease" uninstalled`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + }, + opts: UninstallOptions{ + ReleaseName: "myrelease", + Namespace: "default", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + err := client.Uninstall(t.Context(), tt.opts) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_Render(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts InstallOptions + wantErr bool + }{ + { + name: "successful render", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"template", "myrelease", "/path/to/chart", "--namespace", "default", "--create-namespace", "--include-crds", "--debug"}, + ).Return(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + }, + wantErr: false, + }, + { + name: "render with values", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "template") && + strings.Contains(argsStr, "--values") + }), + ).Return(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Values: map[string]interface{}{ + "key": "value", + }, + }, + wantErr: false, + }, + { + name: "render with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "template") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + opts: InstallOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + // Create temporary directory for the test + tmpdir, err := os.MkdirTemp("", "helm-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpdir) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + tmpdir: tmpdir, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err = client.Render(t.Context(), tt.opts) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} diff --git a/tests/integration/kind/registry/ha_test.go b/tests/integration/kind/registry/ha_test.go index 25d803fd93..f03764d9dc 100644 --- a/tests/integration/kind/registry/ha_test.go +++ b/tests/integration/kind/registry/ha_test.go @@ -148,7 +148,7 @@ func TestRegistry_EnableHAAirgap(t *testing.T) { ) enableHAAndCancelContextOnMessage(t, addOns, inSpec, - regexp.MustCompile(`StatefulSet is ready: seaweedfs`), + regexp.MustCompile(`StatefulSet is ready: seaweedfs-master`), ) enableHAAndCancelContextOnMessage(t, addOns, inSpec, From 79ba2bc90a9d77e7972c9428a1630dc7c6d6fcd9 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 08:53:16 -0700 Subject: [PATCH 15/34] revert --- tests/integration/kind/registry/ha_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/kind/registry/ha_test.go b/tests/integration/kind/registry/ha_test.go index f03764d9dc..25d803fd93 100644 --- a/tests/integration/kind/registry/ha_test.go +++ b/tests/integration/kind/registry/ha_test.go @@ -148,7 +148,7 @@ func TestRegistry_EnableHAAirgap(t *testing.T) { ) enableHAAndCancelContextOnMessage(t, addOns, inSpec, - regexp.MustCompile(`StatefulSet is ready: seaweedfs-master`), + regexp.MustCompile(`StatefulSet is ready: seaweedfs`), ) enableHAAndCancelContextOnMessage(t, addOns, inSpec, From f8c9b385b05dcb6c3580ff3fd245f2066131cee4 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 08:54:58 -0700 Subject: [PATCH 16/34] debug logs --- pkg/addons/seaweedfs/install.go | 3 +++ pkg/addons/seaweedfs/upgrade.go | 10 +++++++++- pkg/helm/client.go | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pkg/addons/seaweedfs/install.go b/pkg/addons/seaweedfs/install.go index d8b1037209..c9ce54533b 100644 --- a/pkg/addons/seaweedfs/install.go +++ b/pkg/addons/seaweedfs/install.go @@ -10,6 +10,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/helpers" + "github.com/sirupsen/logrus" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -23,6 +24,8 @@ func (s *SeaweedFS) Install( kcli client.Client, mcli metadata.Interface, hcli helm.Client, domains ecv1beta1.Domains, overrides []string, ) error { + logrus.Debugf("SeaweedFS.Install: starting install for release '%s' in namespace '%s'", s.ReleaseName(), s.Namespace()) + if err := s.ensurePreRequisites(ctx, kcli); err != nil { return errors.Wrap(err, "create prerequisites") } diff --git a/pkg/addons/seaweedfs/upgrade.go b/pkg/addons/seaweedfs/upgrade.go index ea4370c5ed..6037061fe1 100644 --- a/pkg/addons/seaweedfs/upgrade.go +++ b/pkg/addons/seaweedfs/upgrade.go @@ -17,15 +17,23 @@ func (s *SeaweedFS) Upgrade( kcli client.Client, mcli metadata.Interface, hcli helm.Client, domains ecv1beta1.Domains, overrides []string, ) error { + logrus.Debugf("SeaweedFS.Upgrade: starting upgrade for release '%s' in namespace '%s'", s.ReleaseName(), s.Namespace()) + exists, err := hcli.ReleaseExists(ctx, s.Namespace(), s.ReleaseName()) if err != nil { + logrus.Debugf("SeaweedFS.Upgrade: ReleaseExists failed: %v", err) return errors.Wrap(err, "check if release exists") } + + logrus.Debugf("SeaweedFS.Upgrade: ReleaseExists returned %t", exists) + if !exists { - logrus.Debugf("Release not found, installing release %s in namespace %s", s.ReleaseName(), s.Namespace()) + logrus.Debugf("SeaweedFS.Upgrade: Release not found, installing release %s in namespace %s", s.ReleaseName(), s.Namespace()) return s.Install(ctx, logf, kcli, mcli, hcli, domains, overrides) } + logrus.Debugf("SeaweedFS.Upgrade: Release exists, proceeding with helm upgrade") + if err := s.ensurePreRequisites(ctx, kcli); err != nil { return errors.Wrap(err, "create prerequisites") } diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 618527db61..5c942e83a4 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -279,21 +279,36 @@ func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releas // Add kubeconfig and context if available args = h.addKubernetesEnvArgs(args) + logrus.Debugf("ReleaseExists: checking for release '%s' in namespace '%s'", releaseName, namespace) + logrus.Debugf("ReleaseExists: executing helm command: %s", strings.Join(args, " ")) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { + logrus.Debugf("ReleaseExists: helm list failed: %v, stderr: %s", err, stderr) return false, fmt.Errorf("helm list: %w, stderr: %s", err, stderr) } + logrus.Debugf("ReleaseExists: helm list stdout: %s", stdout) + logrus.Debugf("ReleaseExists: helm list stderr: %s", stderr) + var releases []struct { + Name string `json:"name"` Status release.Status `json:"status"` } if err := json.Unmarshal([]byte(stdout), &releases); err != nil { + logrus.Debugf("ReleaseExists: failed to parse JSON: %v", err) return false, fmt.Errorf("parse release list JSON: %w", err) } + logrus.Debugf("ReleaseExists: found %d releases", len(releases)) + for i, rel := range releases { + logrus.Debugf("ReleaseExists: release[%d]: name='%s', status='%s'", i, rel.Name, rel.Status) + } + // True if release exists and is not uninstalled exists := len(releases) > 0 && releases[len(releases)-1].Status != release.StatusUninstalled + logrus.Debugf("ReleaseExists: returning %t for release '%s' in namespace '%s'", exists, releaseName, namespace) return exists, nil } @@ -323,6 +338,8 @@ func (h *HelmClient) createValuesFile(values map[string]interface{}) (string, er } func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, error) { + logrus.Debugf("HelmClient.Install: starting install for release '%s' in namespace '%s'", opts.ReleaseName, opts.Namespace) + // Build helm install command arguments args := []string{"install", opts.ReleaseName} @@ -381,6 +398,8 @@ func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, // NOTE: we don't set client.Atomic = true on install as it makes installation failures difficult to debug // since it will rollback the release. + logrus.Debugf("HelmClient.Install: executing helm command: %s", strings.Join(args, " ")) + // Execute helm install command stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { From 5ee5bcf7eacb737491f8a3ef276600a0802f9877 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 09:10:51 -0700 Subject: [PATCH 17/34] debug logs --- tests/integration/kind/registry/ha_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/kind/registry/ha_test.go b/tests/integration/kind/registry/ha_test.go index 25d803fd93..31c47b0ebe 100644 --- a/tests/integration/kind/registry/ha_test.go +++ b/tests/integration/kind/registry/ha_test.go @@ -255,6 +255,7 @@ func waitForMatchingMessage(t *testing.T, r io.Reader, re *regexp.Regexp) bool { scanner := bufio.NewScanner(r) for scanner.Scan() { b := scanner.Bytes() + t.Logf("%s got message: %s", formattedTime(), string(b)) if re.Match(b) { t.Logf("%s got matching message: %s", formattedTime(), string(b)) return true From accb891695e35e858a1fba8cede9842c8a27819e Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 09:15:59 -0700 Subject: [PATCH 18/34] no-op From b7821565ec60a1175b6f3821e62fa46bde07b9a1 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 09:37:40 -0700 Subject: [PATCH 19/34] one more try --- pkg/helm/client.go | 43 +++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 5c942e83a4..ebee96d5b5 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -273,42 +273,45 @@ func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version s } func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) { - // Use helm list to check if release exists - args := []string{"list", "--namespace", namespace, "--filter", fmt.Sprintf("^%s$", releaseName), "--output", "json"} + // Use helm status to check if release exists (including pending/failed states) + args := []string{"status", releaseName, "--namespace", namespace, "--output", "json"} // Add kubeconfig and context if available args = h.addKubernetesEnvArgs(args) - logrus.Debugf("ReleaseExists: checking for release '%s' in namespace '%s'", releaseName, namespace) - logrus.Debugf("ReleaseExists: executing helm command: %s", strings.Join(args, " ")) + logrus.Infof("ReleaseExists: checking for release '%s' in namespace '%s'", releaseName, namespace) + logrus.Infof("ReleaseExists: executing helm command: %s", strings.Join(args, " ")) stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - logrus.Debugf("ReleaseExists: helm list failed: %v, stderr: %s", err, stderr) - return false, fmt.Errorf("helm list: %w, stderr: %s", err, stderr) + // helm status returns error if release doesn't exist + if strings.Contains(err.Error(), "release: not found") { + logrus.Infof("ReleaseExists: release not found, returning false") + return false, nil + } + logrus.Infof("ReleaseExists: helm status failed: %v, stderr: %s", err, stderr) + return false, fmt.Errorf("execute command: %w", err) } - logrus.Debugf("ReleaseExists: helm list stdout: %s", stdout) - logrus.Debugf("ReleaseExists: helm list stderr: %s", stderr) + logrus.Infof("ReleaseExists: helm status stdout: %s", stdout) + logrus.Infof("ReleaseExists: helm status stderr: %s", stderr) - var releases []struct { - Name string `json:"name"` - Status release.Status `json:"status"` + var status struct { + Info struct { + Status release.Status `json:"status"` + } `json:"info"` } - if err := json.Unmarshal([]byte(stdout), &releases); err != nil { - logrus.Debugf("ReleaseExists: failed to parse JSON: %v", err) - return false, fmt.Errorf("parse release list JSON: %w", err) + if err := json.Unmarshal([]byte(stdout), &status); err != nil { + logrus.Infof("ReleaseExists: failed to parse JSON: %v", err) + return false, fmt.Errorf("parse release status JSON: %w", err) } - logrus.Debugf("ReleaseExists: found %d releases", len(releases)) - for i, rel := range releases { - logrus.Debugf("ReleaseExists: release[%d]: name='%s', status='%s'", i, rel.Name, rel.Status) - } + logrus.Infof("ReleaseExists: found release with status: %s", status.Info.Status) // True if release exists and is not uninstalled - exists := len(releases) > 0 && releases[len(releases)-1].Status != release.StatusUninstalled + exists := status.Info.Status != release.StatusUninstalled - logrus.Debugf("ReleaseExists: returning %t for release '%s' in namespace '%s'", exists, releaseName, namespace) + logrus.Infof("ReleaseExists: returning %t for release '%s' in namespace '%s'", exists, releaseName, namespace) return exists, nil } From e5b7a8b423deb498b3f2252e8a6337c0ca4cfecc Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 09:42:53 -0700 Subject: [PATCH 20/34] use helm history --- pkg/helm/client.go | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index ebee96d5b5..5def082a87 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -273,8 +273,8 @@ func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version s } func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) { - // Use helm status to check if release exists (including pending/failed states) - args := []string{"status", releaseName, "--namespace", namespace, "--output", "json"} + // Use helm history to check if release exists + args := []string{"history", releaseName, "--namespace", namespace, "--max", "1", "--output", "json"} // Add kubeconfig and context if available args = h.addKubernetesEnvArgs(args) @@ -284,32 +284,35 @@ func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releas stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - // helm status returns error if release doesn't exist if strings.Contains(err.Error(), "release: not found") { logrus.Infof("ReleaseExists: release not found, returning false") return false, nil } - logrus.Infof("ReleaseExists: helm status failed: %v, stderr: %s", err, stderr) + logrus.Infof("ReleaseExists: helm history failed: %v, stderr: %s", err, stderr) return false, fmt.Errorf("execute command: %w", err) } - logrus.Infof("ReleaseExists: helm status stdout: %s", stdout) - logrus.Infof("ReleaseExists: helm status stderr: %s", stderr) + logrus.Infof("ReleaseExists: helm history stdout: %s", stdout) + logrus.Infof("ReleaseExists: helm history stderr: %s", stderr) - var status struct { - Info struct { - Status release.Status `json:"status"` - } `json:"info"` + var history []struct { + Status release.Status `json:"status"` } - if err := json.Unmarshal([]byte(stdout), &status); err != nil { + if err := json.Unmarshal([]byte(stdout), &history); err != nil { logrus.Infof("ReleaseExists: failed to parse JSON: %v", err) - return false, fmt.Errorf("parse release status JSON: %w", err) + return false, fmt.Errorf("parse release history JSON: %w", err) } - logrus.Infof("ReleaseExists: found release with status: %s", status.Info.Status) + if len(history) == 0 { + logrus.Infof("ReleaseExists: no history found, returning false") + return false, nil + } + + logrus.Infof("ReleaseExists: found release with status: %s", history[0].Status) + // Equivalent to: isReleaseUninstalled(versions) check // True if release exists and is not uninstalled - exists := status.Info.Status != release.StatusUninstalled + exists := history[0].Status != release.StatusUninstalled logrus.Infof("ReleaseExists: returning %t for release '%s' in namespace '%s'", exists, releaseName, namespace) return exists, nil From bc7f39353c9fcb8a7d0536b9410c0f24967ba236 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 12:40:42 -0700 Subject: [PATCH 21/34] override cmd cancel function --- pkg/addons/seaweedfs/install.go | 3 - pkg/addons/seaweedfs/upgrade.go | 9 --- pkg/helm/binary_executor.go | 7 ++ pkg/helm/client.go | 28 +------ pkg/helm/client_test.go | 133 ++++++++++++++++++++++++++++++-- pkg/helpers/command.go | 3 + pkg/helpers/interface.go | 3 + 7 files changed, 144 insertions(+), 42 deletions(-) diff --git a/pkg/addons/seaweedfs/install.go b/pkg/addons/seaweedfs/install.go index c9ce54533b..d8b1037209 100644 --- a/pkg/addons/seaweedfs/install.go +++ b/pkg/addons/seaweedfs/install.go @@ -10,7 +10,6 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/helpers" - "github.com/sirupsen/logrus" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -24,8 +23,6 @@ func (s *SeaweedFS) Install( kcli client.Client, mcli metadata.Interface, hcli helm.Client, domains ecv1beta1.Domains, overrides []string, ) error { - logrus.Debugf("SeaweedFS.Install: starting install for release '%s' in namespace '%s'", s.ReleaseName(), s.Namespace()) - if err := s.ensurePreRequisites(ctx, kcli); err != nil { return errors.Wrap(err, "create prerequisites") } diff --git a/pkg/addons/seaweedfs/upgrade.go b/pkg/addons/seaweedfs/upgrade.go index 6037061fe1..20e7eb5063 100644 --- a/pkg/addons/seaweedfs/upgrade.go +++ b/pkg/addons/seaweedfs/upgrade.go @@ -7,7 +7,6 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" - "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -17,23 +16,15 @@ func (s *SeaweedFS) Upgrade( kcli client.Client, mcli metadata.Interface, hcli helm.Client, domains ecv1beta1.Domains, overrides []string, ) error { - logrus.Debugf("SeaweedFS.Upgrade: starting upgrade for release '%s' in namespace '%s'", s.ReleaseName(), s.Namespace()) - exists, err := hcli.ReleaseExists(ctx, s.Namespace(), s.ReleaseName()) if err != nil { - logrus.Debugf("SeaweedFS.Upgrade: ReleaseExists failed: %v", err) return errors.Wrap(err, "check if release exists") } - logrus.Debugf("SeaweedFS.Upgrade: ReleaseExists returned %t", exists) - if !exists { - logrus.Debugf("SeaweedFS.Upgrade: Release not found, installing release %s in namespace %s", s.ReleaseName(), s.Namespace()) return s.Install(ctx, logf, kcli, mcli, hcli, domains, overrides) } - logrus.Debugf("SeaweedFS.Upgrade: Release exists, proceeding with helm upgrade") - if err := s.ensurePreRequisites(ctx, kcli); err != nil { return errors.Wrap(err, "create prerequisites") } diff --git a/pkg/helm/binary_executor.go b/pkg/helm/binary_executor.go index d74800ae2c..d4020b6da9 100644 --- a/pkg/helm/binary_executor.go +++ b/pkg/helm/binary_executor.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "io" + "os/exec" "regexp" "strings" + "syscall" "github.com/replicatedhq/embedded-cluster/pkg/helpers" ) @@ -37,6 +39,11 @@ func (c *binaryExecutor) ExecuteCommand(ctx context.Context, env map[string]stri Stdout: &stdout, Stderr: io.MultiWriter(&stderr, logWriter), // Helm uses stderr for debug logging and progress Env: env, + Cancel: func(cmd *exec.Cmd) { + cmd.Cancel = func() error { + return cmd.Process.Signal(syscall.SIGTERM) + } + }, }, c.bin, args...) return stdout.String(), stderr.String(), err diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 5def082a87..0b4eb72401 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -279,42 +279,24 @@ func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releas // Add kubeconfig and context if available args = h.addKubernetesEnvArgs(args) - logrus.Infof("ReleaseExists: checking for release '%s' in namespace '%s'", releaseName, namespace) - logrus.Infof("ReleaseExists: executing helm command: %s", strings.Join(args, " ")) - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - if strings.Contains(err.Error(), "release: not found") { - logrus.Infof("ReleaseExists: release not found, returning false") + if strings.Contains(err.Error(), "release: not found") || strings.Contains(stderr, "release: not found") { return false, nil } - logrus.Infof("ReleaseExists: helm history failed: %v, stderr: %s", err, stderr) return false, fmt.Errorf("execute command: %w", err) } - logrus.Infof("ReleaseExists: helm history stdout: %s", stdout) - logrus.Infof("ReleaseExists: helm history stderr: %s", stderr) - var history []struct { Status release.Status `json:"status"` } if err := json.Unmarshal([]byte(stdout), &history); err != nil { - logrus.Infof("ReleaseExists: failed to parse JSON: %v", err) - return false, fmt.Errorf("parse release history JSON: %w", err) - } - - if len(history) == 0 { - logrus.Infof("ReleaseExists: no history found, returning false") - return false, nil + return false, fmt.Errorf("parse release history json: %w", err) } - logrus.Infof("ReleaseExists: found release with status: %s", history[0].Status) - - // Equivalent to: isReleaseUninstalled(versions) check - // True if release exists and is not uninstalled - exists := history[0].Status != release.StatusUninstalled + // True if release has history and is not uninstalled + exists := len(history) > 0 && history[len(history)-1].Status != release.StatusUninstalled - logrus.Infof("ReleaseExists: returning %t for release '%s' in namespace '%s'", exists, releaseName, namespace) return exists, nil } @@ -404,8 +386,6 @@ func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, // NOTE: we don't set client.Atomic = true on install as it makes installation failures difficult to debug // since it will rollback the release. - logrus.Debugf("HelmClient.Install: executing helm command: %s", strings.Join(args, " ")) - // Execute helm install command stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { diff --git a/pkg/helm/client_test.go b/pkg/helm/client_test.go index c267df2b76..d7dafce26a 100644 --- a/pkg/helm/client_test.go +++ b/pkg/helm/client_test.go @@ -1,6 +1,7 @@ package helm import ( + "fmt" "os" "strings" "testing" @@ -301,20 +302,20 @@ func TestHelmClient_ReleaseExists(t *testing.T) { mock.Anything, // LogFn mock.MatchedBy(func(args []string) bool { argsStr := strings.Join(args, " ") - return strings.HasPrefix(argsStr, "list") && + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && strings.Contains(argsStr, "--namespace default") && - strings.Contains(argsStr, "--filter ^myrelease$") && + strings.Contains(argsStr, "--max 1") && strings.Contains(argsStr, "--output json") && strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") }), ).Return(`[{ - "name": "myrelease", - "namespace": "default", "revision": 1, "updated": "2023-01-01T00:00:00Z", "status": "deployed", "chart": "test-chart-1.0.0", - "app_version": "1.0.0" + "app_version": "1.0.0", + "description": "Install complete" }]`, "", nil) }, kubernetesEnvSettings: &helmcli.EnvSettings{ @@ -332,7 +333,7 @@ func TestHelmClient_ReleaseExists(t *testing.T) { mock.Anything, // context mock.Anything, // env mock.Anything, // LogFn - []string{"list", "--namespace", "default", "--filter", "^myrelease$", "--output", "json"}, + []string{"history", "myrelease", "--namespace", "default", "--max", "1", "--output", "json"}, ).Return(`[]`, "", nil) }, kubernetesEnvSettings: nil, // No kubeconfig settings @@ -341,6 +342,126 @@ func TestHelmClient_ReleaseExists(t *testing.T) { want: false, wantErr: false, }, + { + name: "release exists but is uninstalled", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--max 1") && + strings.Contains(argsStr, "--output json") + }), + ).Return(`[{ + "revision": 2, + "updated": "2023-01-01T01:00:00Z", + "status": "uninstalled", + "chart": "test-chart-1.0.0", + "app_version": "1.0.0", + "description": "Uninstallation complete" + }]`, "", nil) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: false, + }, + { + name: "release exists in pending-install state", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--max 1") && + strings.Contains(argsStr, "--output json") + }), + ).Return(`[{ + "revision": 1, + "updated": "2023-01-01T00:00:00Z", + "status": "pending-install", + "chart": "test-chart-1.0.0", + "app_version": "1.0.0", + "description": "Install in progress" + }]`, "", nil) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + want: true, + wantErr: false, + }, + { + name: "release not found error in stderr", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") + }), + ).Return("", "release: not found", fmt.Errorf("exit status 1")) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: false, + }, + { + name: "release not found error in err message", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") + }), + ).Return("", "", fmt.Errorf("release: not found")) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: false, + }, + { + name: "other command execution error", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") + }), + ).Return("", "connection refused", fmt.Errorf("exit status 1")) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + want: false, + wantErr: true, + }, } for _, tt := range tests { diff --git a/pkg/helpers/command.go b/pkg/helpers/command.go index 5e86ba4a73..351a46a4cd 100644 --- a/pkg/helpers/command.go +++ b/pkg/helpers/command.go @@ -23,6 +23,9 @@ func (h *Helpers) RunCommandWithOptions(opts RunCommandOptions, bin string, args stderr := bytes.NewBuffer(nil) stdout := bytes.NewBuffer(nil) cmd := exec.CommandContext(ctx, bin, args...) + if opts.Cancel != nil { + opts.Cancel(cmd) + } cmd.Stdout = stdout if opts.Stdout != nil { cmd.Stdout = io.MultiWriter(opts.Stdout, stdout) diff --git a/pkg/helpers/interface.go b/pkg/helpers/interface.go index 4e207de0a3..d70bfe1042 100644 --- a/pkg/helpers/interface.go +++ b/pkg/helpers/interface.go @@ -3,6 +3,7 @@ package helpers import ( "context" "io" + "os/exec" ) var h HelpersInterface @@ -39,6 +40,8 @@ type RunCommandOptions struct { Stdin io.Reader // LogOnSuccess makes the command output to be logged even when it succeeds. LogOnSuccess bool + // Cancel is a function to cancel the command. + Cancel func(cmd *exec.Cmd) } // Convenience functions From d2f4a7273e12c064d5ec99d9f5112a46bcad7f66 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 13:36:07 -0700 Subject: [PATCH 22/34] auto rollback --- pkg/helm/binary_executor.go | 3 +- pkg/helm/client.go | 170 +++++++++++++++++++++++++++++------- pkg/helpers/command.go | 12 ++- pkg/helpers/interface.go | 4 +- 4 files changed, 151 insertions(+), 38 deletions(-) diff --git a/pkg/helm/binary_executor.go b/pkg/helm/binary_executor.go index d4020b6da9..131e275bba 100644 --- a/pkg/helm/binary_executor.go +++ b/pkg/helm/binary_executor.go @@ -39,8 +39,9 @@ func (c *binaryExecutor) ExecuteCommand(ctx context.Context, env map[string]stri Stdout: &stdout, Stderr: io.MultiWriter(&stderr, logWriter), // Helm uses stderr for debug logging and progress Env: env, - Cancel: func(cmd *exec.Cmd) { + ModifyCmd: func(cmd *exec.Cmd) { cmd.Cancel = func() error { + // Cancel function defaults to SIGKILL, but Helm only handles SIGINT and SIGTERM gracefully return cmd.Process.Signal(syscall.SIGTERM) } }, diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 0b4eb72401..6ee91c9840 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -88,6 +88,15 @@ type UninstallOptions struct { LogFn LogFn // Log function override to use for uninstall command } +type RollbackOptions struct { + ReleaseName string + Namespace string + Revision int // Target revision to rollback to, 0 for automatic + Timeout time.Duration + Force bool + LogFn LogFn // Log function override to use for rollback command +} + type HelmClient struct { helmPath string // Path to helm binary executor BinaryExecutor // Mockable executor @@ -102,9 +111,9 @@ func (h *HelmClient) prepare(ctx context.Context) error { // Update all repositories to ensure we have the latest chart information for _, repo := range h.repositories { args := []string{"repo", "update", repo.Name} - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("helm repo update %s: %w, stderr: %s", repo.Name, err, stderr) + return fmt.Errorf("execute command: %w", err) } } return nil @@ -136,9 +145,9 @@ func (h *HelmClient) AddRepo(ctx context.Context, repo *repo.Entry) error { args = append(args, "--pass-credentials") } - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("helm repo add: %w, stderr: %s", err, stderr) + return fmt.Errorf("execute command: %w", err) } // Store the repository entry for future reference @@ -150,9 +159,9 @@ func (h *HelmClient) Latest(ctx context.Context, reponame, chart string) (string // Use helm search repo with JSON output to find the latest version args := []string{"search", "repo", fmt.Sprintf("%s/%s", reponame, chart), "--version", ">0.0.0", "--versions", "--output", "json"} - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return "", fmt.Errorf("helm search repo: %w, stderr: %s", err, stderr) + return "", fmt.Errorf("execute command: %w", err) } // Parse JSON output @@ -212,9 +221,9 @@ func (h *HelmClient) PullByRef(ctx context.Context, ref string, version string) // Add debug flag to report progress and capture debug logs args = append(args, "--debug") - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return "", fmt.Errorf("helm pull: %w, stderr: %s", err, stderr) + return "", fmt.Errorf("execute command: %w", err) } // Get chart metadata to determine the actual chart name and construct filename @@ -233,9 +242,9 @@ func (h *HelmClient) RegistryAuth(ctx context.Context, server, user, pass string // Use helm registry login for authentication args := []string{"registry", "login", server, "--username", user, "--password", pass} - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("helm registry login: %w, stderr: %s", err, stderr) + return fmt.Errorf("execute command: %w", err) } return nil @@ -245,9 +254,9 @@ func (h *HelmClient) Push(ctx context.Context, path, dst string) error { // Use helm push to upload the chart args := []string{"push", path, dst} - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("helm push: %w, stderr: %s", err, stderr) + return fmt.Errorf("execute command: %w", err) } return nil @@ -260,9 +269,9 @@ func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version s args = append(args, "--version", version) } - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return nil, fmt.Errorf("helm show chart: %w, stderr: %s", err, stderr) + return nil, fmt.Errorf("execute command: %w", err) } var metadata chart.Metadata @@ -272,9 +281,19 @@ func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version s return &metadata, nil } -func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) { - // Use helm history to check if release exists - args := []string{"history", releaseName, "--namespace", namespace, "--max", "1", "--output", "json"} +// ReleaseHistoryEntry represents a single entry in helm release history +type ReleaseHistoryEntry struct { + Revision int `json:"revision"` + Status release.Status `json:"status"` +} + +// ReleaseHistory returns the release history for a given release +func (h *HelmClient) ReleaseHistory(ctx context.Context, namespace string, releaseName string, maxRevisions int) ([]ReleaseHistoryEntry, error) { + args := []string{"history", releaseName, "--namespace", namespace, "--output", "json"} + + if maxRevisions > 0 { + args = append(args, "--max", fmt.Sprintf("%d", maxRevisions)) + } // Add kubeconfig and context if available args = h.addKubernetesEnvArgs(args) @@ -282,20 +301,41 @@ func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releas stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { if strings.Contains(err.Error(), "release: not found") || strings.Contains(stderr, "release: not found") { - return false, nil + return nil, nil } - return false, fmt.Errorf("execute command: %w", err) + return nil, fmt.Errorf("execute command: %w", err) } - var history []struct { - Status release.Status `json:"status"` - } + var history []ReleaseHistoryEntry if err := json.Unmarshal([]byte(stdout), &history); err != nil { - return false, fmt.Errorf("parse release history json: %w", err) + return nil, fmt.Errorf("parse release history json: %w", err) + } + + return history, nil +} + +// GetLastRevision returns the revision number of the latest release entry +func (h *HelmClient) GetLastRevision(ctx context.Context, namespace string, releaseName string) (int, error) { + history, err := h.ReleaseHistory(ctx, namespace, releaseName, 1) + if err != nil { + return 0, fmt.Errorf("get release history: %w", err) + } + + if len(history) == 0 { + return 0, fmt.Errorf("no release history found for %s", releaseName) + } + + return history[0].Revision, nil +} + +func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) { + history, err := h.ReleaseHistory(ctx, namespace, releaseName, 1) + if err != nil { + return false, fmt.Errorf("get release history: %w", err) } // True if release has history and is not uninstalled - exists := len(history) > 0 && history[len(history)-1].Status != release.StatusUninstalled + exists := len(history) > 0 && history[0].Status != release.StatusUninstalled return exists, nil } @@ -387,9 +427,9 @@ func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, // since it will rollback the release. // Execute helm install command - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return "", fmt.Errorf("helm install: %w, stderr: %s", err, stderr) + return "", fmt.Errorf("execute command: %w", err) } return stdout, nil @@ -477,9 +517,75 @@ func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, args = append(args, "--debug") // Execute helm upgrade command - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + if err != nil { + if strings.Contains(err.Error(), "another operation") && strings.Contains(err.Error(), "in progress") { + // Get the last revision + lastRevision, err := h.GetLastRevision(ctx, opts.Namespace, opts.ReleaseName) + if err != nil { + return "", fmt.Errorf("get last revision: %w", err) + } + + // Rollback to the latest revision + if _, err := h.Rollback(ctx, RollbackOptions{ + ReleaseName: opts.ReleaseName, + Namespace: opts.Namespace, + Revision: lastRevision, + Timeout: opts.Timeout, + Force: opts.Force, + LogFn: opts.LogFn, + }); err != nil { + return "", fmt.Errorf("rollback: %w", err) + } + + // Retry upgrade after successful rollback + return h.Upgrade(ctx, opts) + } + + return "", fmt.Errorf("execute command: %w", err) + } + + return stdout, nil +} + +func (h *HelmClient) Rollback(ctx context.Context, opts RollbackOptions) (string, error) { + args := []string{"rollback", opts.ReleaseName} + + // If specific revision is provided, use it + if opts.Revision > 0 { + args = append(args, fmt.Sprintf("%d", opts.Revision)) + } + + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) + } + + // Add wait options + args = append(args, "--wait") + args = append(args, "--wait-for-jobs") + + // Add timeout + timeout := opts.Timeout + if timeout == 0 { + timeout = 5 * time.Minute + } + args = append(args, "--timeout", timeout.String()) + + // Add force flag if specified + if opts.Force { + args = append(args, "--force") + } + + // Add kubernetes environment arguments + args = h.addKubernetesEnvArgs(args) + + // Add debug flag to report progress and capture debug logs + args = append(args, "--debug") + + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return "", fmt.Errorf("helm upgrade: %w, stderr: %s", err, stderr) + return "", fmt.Errorf("execute command: %w", err) } return stdout, nil @@ -517,9 +623,9 @@ func (h *HelmClient) Uninstall(ctx context.Context, opts UninstallOptions) error } // Execute helm uninstall command - _, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + _, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return fmt.Errorf("helm uninstall: %w, stderr: %s", err, stderr) + return fmt.Errorf("execute command: %w", err) } return nil @@ -576,9 +682,9 @@ func (h *HelmClient) Render(ctx context.Context, opts InstallOptions) ([][]byte, args = append(args, "--debug") // Execute helm template command - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return nil, fmt.Errorf("helm template: %w, stderr: %s", err, stderr) + return nil, fmt.Errorf("execute command: %w", err) } manifests, err := splitManifests(stdout) diff --git a/pkg/helpers/command.go b/pkg/helpers/command.go index 351a46a4cd..f0be560dd8 100644 --- a/pkg/helpers/command.go +++ b/pkg/helpers/command.go @@ -23,25 +23,31 @@ func (h *Helpers) RunCommandWithOptions(opts RunCommandOptions, bin string, args stderr := bytes.NewBuffer(nil) stdout := bytes.NewBuffer(nil) cmd := exec.CommandContext(ctx, bin, args...) - if opts.Cancel != nil { - opts.Cancel(cmd) - } + cmd.Stdout = stdout if opts.Stdout != nil { cmd.Stdout = io.MultiWriter(opts.Stdout, stdout) } + if opts.Stdin != nil { cmd.Stdin = opts.Stdin } + cmd.Stderr = stderr if opts.Stderr != nil { cmd.Stderr = io.MultiWriter(opts.Stderr, stderr) } + cmdEnv := cmd.Environ() for k, v := range opts.Env { cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v)) } cmd.Env = cmdEnv + + if opts.ModifyCmd != nil { + opts.ModifyCmd(cmd) + } + if err := cmd.Run(); err != nil { logrus.Debugf("failed to run command:") logrus.Debugf("stdout: %s", stdout.String()) diff --git a/pkg/helpers/interface.go b/pkg/helpers/interface.go index d70bfe1042..09b8600aa3 100644 --- a/pkg/helpers/interface.go +++ b/pkg/helpers/interface.go @@ -40,8 +40,8 @@ type RunCommandOptions struct { Stdin io.Reader // LogOnSuccess makes the command output to be logged even when it succeeds. LogOnSuccess bool - // Cancel is a function to cancel the command. - Cancel func(cmd *exec.Cmd) + // ModifyCmd is a function to modify the command before execution. + ModifyCmd func(cmd *exec.Cmd) } // Convenience functions From 1bf3835c931c7f28167ff0b7bb2a88d294568da2 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 13:36:41 -0700 Subject: [PATCH 23/34] remove modify cmd --- pkg/helm/binary_executor.go | 8 -------- pkg/helpers/command.go | 4 ---- pkg/helpers/interface.go | 3 --- 3 files changed, 15 deletions(-) diff --git a/pkg/helm/binary_executor.go b/pkg/helm/binary_executor.go index 131e275bba..d74800ae2c 100644 --- a/pkg/helm/binary_executor.go +++ b/pkg/helm/binary_executor.go @@ -4,10 +4,8 @@ import ( "bytes" "context" "io" - "os/exec" "regexp" "strings" - "syscall" "github.com/replicatedhq/embedded-cluster/pkg/helpers" ) @@ -39,12 +37,6 @@ func (c *binaryExecutor) ExecuteCommand(ctx context.Context, env map[string]stri Stdout: &stdout, Stderr: io.MultiWriter(&stderr, logWriter), // Helm uses stderr for debug logging and progress Env: env, - ModifyCmd: func(cmd *exec.Cmd) { - cmd.Cancel = func() error { - // Cancel function defaults to SIGKILL, but Helm only handles SIGINT and SIGTERM gracefully - return cmd.Process.Signal(syscall.SIGTERM) - } - }, }, c.bin, args...) return stdout.String(), stderr.String(), err diff --git a/pkg/helpers/command.go b/pkg/helpers/command.go index f0be560dd8..5ed456b9e9 100644 --- a/pkg/helpers/command.go +++ b/pkg/helpers/command.go @@ -44,10 +44,6 @@ func (h *Helpers) RunCommandWithOptions(opts RunCommandOptions, bin string, args } cmd.Env = cmdEnv - if opts.ModifyCmd != nil { - opts.ModifyCmd(cmd) - } - if err := cmd.Run(); err != nil { logrus.Debugf("failed to run command:") logrus.Debugf("stdout: %s", stdout.String()) diff --git a/pkg/helpers/interface.go b/pkg/helpers/interface.go index 09b8600aa3..4e207de0a3 100644 --- a/pkg/helpers/interface.go +++ b/pkg/helpers/interface.go @@ -3,7 +3,6 @@ package helpers import ( "context" "io" - "os/exec" ) var h HelpersInterface @@ -40,8 +39,6 @@ type RunCommandOptions struct { Stdin io.Reader // LogOnSuccess makes the command output to be logged even when it succeeds. LogOnSuccess bool - // ModifyCmd is a function to modify the command before execution. - ModifyCmd func(cmd *exec.Cmd) } // Convenience functions From 091260dc225d3f2f913db75dbca9dd40e75a1c08 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 14:18:21 -0700 Subject: [PATCH 24/34] tests --- pkg/helm/client.go | 16 +- pkg/helm/client_test.go | 410 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 403 insertions(+), 23 deletions(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 6ee91c9840..bc39abb31a 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -298,11 +298,8 @@ func (h *HelmClient) ReleaseHistory(ctx context.Context, namespace string, relea // Add kubeconfig and context if available args = h.addKubernetesEnvArgs(args) - stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - if strings.Contains(err.Error(), "release: not found") || strings.Contains(stderr, "release: not found") { - return nil, nil - } return nil, fmt.Errorf("execute command: %w", err) } @@ -331,6 +328,9 @@ func (h *HelmClient) GetLastRevision(ctx context.Context, namespace string, rele func (h *HelmClient) ReleaseExists(ctx context.Context, namespace string, releaseName string) (bool, error) { history, err := h.ReleaseHistory(ctx, namespace, releaseName, 1) if err != nil { + if strings.Contains(err.Error(), "release: not found") { + return false, nil + } return false, fmt.Errorf("get release history: %w", err) } @@ -517,9 +517,9 @@ func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, args = append(args, "--debug") // Execute helm upgrade command - stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - if strings.Contains(err.Error(), "another operation") && strings.Contains(err.Error(), "in progress") { + if isOperationInProgressError(err.Error()) || isOperationInProgressError(stderr) { // Get the last revision lastRevision, err := h.GetLastRevision(ctx, opts.Namespace, opts.ReleaseName) if err != nil { @@ -548,6 +548,10 @@ func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, return stdout, nil } +func isOperationInProgressError(err string) bool { + return strings.Contains(err, "another operation") && strings.Contains(err, "in progress") +} + func (h *HelmClient) Rollback(ctx context.Context, opts RollbackOptions) (string, error) { args := []string{"rollback", opts.ReleaseName} diff --git a/pkg/helm/client_test.go b/pkg/helm/client_test.go index d7dafce26a..7b87e2c2ff 100644 --- a/pkg/helm/client_test.go +++ b/pkg/helm/client_test.go @@ -333,7 +333,7 @@ func TestHelmClient_ReleaseExists(t *testing.T) { mock.Anything, // context mock.Anything, // env mock.Anything, // LogFn - []string{"history", "myrelease", "--namespace", "default", "--max", "1", "--output", "json"}, + []string{"history", "myrelease", "--namespace", "default", "--output", "json", "--max", "1"}, ).Return(`[]`, "", nil) }, kubernetesEnvSettings: nil, // No kubeconfig settings @@ -403,7 +403,7 @@ func TestHelmClient_ReleaseExists(t *testing.T) { wantErr: false, }, { - name: "release not found error in stderr", + name: "release not found error in err message", setupMock: func(m *MockBinaryExecutor) { m.On("ExecuteCommand", mock.Anything, // context @@ -414,7 +414,7 @@ func TestHelmClient_ReleaseExists(t *testing.T) { return strings.HasPrefix(argsStr, "history") && strings.Contains(argsStr, "myrelease") }), - ).Return("", "release: not found", fmt.Errorf("exit status 1")) + ).Return("", "", fmt.Errorf("release: not found")) }, kubernetesEnvSettings: nil, namespace: "default", @@ -423,7 +423,7 @@ func TestHelmClient_ReleaseExists(t *testing.T) { wantErr: false, }, { - name: "release not found error in err message", + name: "other command execution error", setupMock: func(m *MockBinaryExecutor) { m.On("ExecuteCommand", mock.Anything, // context @@ -434,16 +434,16 @@ func TestHelmClient_ReleaseExists(t *testing.T) { return strings.HasPrefix(argsStr, "history") && strings.Contains(argsStr, "myrelease") }), - ).Return("", "", fmt.Errorf("release: not found")) + ).Return("", "connection refused", fmt.Errorf("exit status 1")) }, kubernetesEnvSettings: nil, namespace: "default", releaseName: "myrelease", want: false, - wantErr: false, + wantErr: true, }, { - name: "other command execution error", + name: "release exists with kubernetes env settings", setupMock: func(m *MockBinaryExecutor) { m.On("ExecuteCommand", mock.Anything, // context @@ -452,15 +452,49 @@ func TestHelmClient_ReleaseExists(t *testing.T) { mock.MatchedBy(func(args []string) bool { argsStr := strings.Join(args, " ") return strings.HasPrefix(argsStr, "history") && - strings.Contains(argsStr, "myrelease") + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--max 1") && + strings.Contains(argsStr, "--output json") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") }), - ).Return("", "connection refused", fmt.Errorf("exit status 1")) + ).Return(`[{ + "revision": 1, + "updated": "2023-01-01T00:00:00Z", + "status": "deployed", + "chart": "test-chart-1.0.0", + "app_version": "1.0.0", + "description": "Install complete" + }]`, "", nil) }, - kubernetesEnvSettings: nil, - namespace: "default", - releaseName: "myrelease", - want: false, - wantErr: true, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + namespace: "default", + releaseName: "myrelease", + want: true, + wantErr: false, }, } @@ -888,6 +922,50 @@ func TestHelmClient_Upgrade(t *testing.T) { }, wantErr: false, }, + { + name: "upgrade with rollback recovery on another operation in progress", + setupMock: func(m *MockBinaryExecutor) { + // First upgrade attempt fails with "another operation in progress" + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"upgrade", "myrelease", "/path/to/chart", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "3m0s", "--atomic", "--debug"}, + ).Return("", "Error: another operation (install/upgrade/rollback) is in progress", fmt.Errorf("exit status 1")).Once() + + // GetLastRevision call (via ReleaseHistory) + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"history", "myrelease", "--namespace", "default", "--output", "json", "--max", "1"}, + ).Return(`[{"revision": 2, "status": "deployed"}]`, "", nil).Once() + + // Rollback call + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"rollback", "myrelease", "2", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "3m0s", "--debug"}, + ).Return("Rollback was a success! Happy Helming!", "", nil).Once() + + // Second upgrade attempt succeeds after rollback + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"upgrade", "myrelease", "/path/to/chart", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "3m0s", "--atomic", "--debug"}, + ).Return(`Release "myrelease" has been upgraded.`, "", nil).Once() + }, + kubernetesEnvSettings: nil, + opts: UpgradeOptions{ + ReleaseName: "myrelease", + ChartPath: "/path/to/chart", + Namespace: "default", + Timeout: 3 * time.Minute, + }, + wantErr: false, + }, } for _, tt := range tests { @@ -956,13 +1034,32 @@ func TestHelmClient_Uninstall(t *testing.T) { argsStr := strings.Join(args, " ") return strings.HasPrefix(argsStr, "uninstall") && strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && - strings.Contains(argsStr, "--kube-context test-context") + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") }), ).Return(`release "myrelease" uninstalled`, "", nil) }, kubernetesEnvSettings: &helmcli.EnvSettings{ - KubeConfig: "/tmp/test-kubeconfig", - KubeContext: "test-context", + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, }, opts: UninstallOptions{ ReleaseName: "myrelease", @@ -1135,3 +1232,282 @@ metadata: }) } } +func TestHelmClient_ReleaseHistory(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + namespace string + releaseName string + maxRevisions int + wantErr bool + }{ + { + name: "successful history retrieval", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"history", "myrelease", "--namespace", "default", "--output", "json", "--max", "5"}, + ).Return(`[{"revision": 1, "status": "superseded"}, {"revision": 2, "status": "superseded"}, {"revision": 3, "status": "deployed"}]`, "", nil) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + maxRevisions: 5, + wantErr: false, + }, + { + name: "history with kubernetes env settings", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--output json") && + strings.Contains(argsStr, "--max 3") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`[{"revision": 1, "status": "deployed"}]`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + namespace: "default", + releaseName: "myrelease", + maxRevisions: 3, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err := client.ReleaseHistory(t.Context(), tt.namespace, tt.releaseName, tt.maxRevisions) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_GetLastRevision(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + namespace string + releaseName string + wantErr bool + }{ + { + name: "successful get last revision", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"history", "myrelease", "--namespace", "default", "--output", "json", "--max", "1"}, + ).Return(`[{"revision": 3, "status": "deployed"}]`, "", nil) + }, + kubernetesEnvSettings: nil, + namespace: "default", + releaseName: "myrelease", + wantErr: false, + }, + { + name: "get last revision with kubeconfig", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "history") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--output json") && + strings.Contains(argsStr, "--max 1") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") && + strings.Contains(argsStr, "--kube-context test-context") && + strings.Contains(argsStr, "--kube-token test-token") && + strings.Contains(argsStr, "--kube-as-user test-user") && + strings.Contains(argsStr, "--kube-as-group test-group1") && + strings.Contains(argsStr, "--kube-as-group test-group2") && + strings.Contains(argsStr, "--kube-apiserver https://test-server:6443") && + strings.Contains(argsStr, "--kube-ca-file /tmp/ca.crt") && + strings.Contains(argsStr, "--kube-tls-server-name test-server") && + strings.Contains(argsStr, "--kube-insecure-skip-tls-verify") && + strings.Contains(argsStr, "--burst-limit 100") && + strings.Contains(argsStr, "--qps 50.00") + }), + ).Return(`[{"revision": 5, "status": "deployed"}]`, "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + KubeContext: "test-context", + KubeToken: "test-token", + KubeAsUser: "test-user", + KubeAsGroups: []string{"test-group1", "test-group2"}, + KubeAPIServer: "https://test-server:6443", + KubeCaFile: "/tmp/ca.crt", + KubeTLSServerName: "test-server", + KubeInsecureSkipTLSVerify: true, + BurstLimit: 100, + QPS: 50.0, + }, + namespace: "default", + releaseName: "myrelease", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err := client.GetLastRevision(t.Context(), tt.namespace, tt.releaseName) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} + +func TestHelmClient_Rollback(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockBinaryExecutor) + kubernetesEnvSettings *helmcli.EnvSettings + opts RollbackOptions + wantErr bool + }{ + { + name: "successful rollback", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + []string{"rollback", "myrelease", "2", "--namespace", "default", "--wait", "--wait-for-jobs", "--timeout", "5m0s", "--debug"}, + ).Return("Rollback was a success! Happy Helming!", "", nil) + }, + kubernetesEnvSettings: nil, + opts: RollbackOptions{ + ReleaseName: "myrelease", + Namespace: "default", + Revision: 2, + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, + { + name: "rollback with kubeconfig", + setupMock: func(m *MockBinaryExecutor) { + m.On("ExecuteCommand", + mock.Anything, // context + mock.Anything, // env + mock.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + argsStr := strings.Join(args, " ") + return strings.HasPrefix(argsStr, "rollback") && + strings.Contains(argsStr, "myrelease") && + strings.Contains(argsStr, "3") && + strings.Contains(argsStr, "--namespace default") && + strings.Contains(argsStr, "--wait") && + strings.Contains(argsStr, "--wait-for-jobs") && + strings.Contains(argsStr, "--timeout 5m0s") && + strings.Contains(argsStr, "--debug") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") + }), + ).Return("Rollback was a success! Happy Helming!", "", nil) + }, + kubernetesEnvSettings: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + }, + opts: RollbackOptions{ + ReleaseName: "myrelease", + Namespace: "default", + Revision: 3, + Timeout: 5 * time.Minute, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &MockBinaryExecutor{} + tt.setupMock(mockExec) + + client := &HelmClient{ + helmPath: "/usr/local/bin/helm", + executor: mockExec, + kubernetesEnvSettings: tt.kubernetesEnvSettings, + } + + _, err := client.Rollback(t.Context(), tt.opts) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + mockExec.AssertExpectations(t) + }) + } +} From 6845b48f05c9079b65dfdbc82a46fb32bfbe3fe7 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 14:19:27 -0700 Subject: [PATCH 25/34] put log back --- pkg/addons/seaweedfs/upgrade.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/addons/seaweedfs/upgrade.go b/pkg/addons/seaweedfs/upgrade.go index 20e7eb5063..ea4370c5ed 100644 --- a/pkg/addons/seaweedfs/upgrade.go +++ b/pkg/addons/seaweedfs/upgrade.go @@ -7,6 +7,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/addons/types" "github.com/replicatedhq/embedded-cluster/pkg/helm" + "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -20,8 +21,8 @@ func (s *SeaweedFS) Upgrade( if err != nil { return errors.Wrap(err, "check if release exists") } - if !exists { + logrus.Debugf("Release not found, installing release %s in namespace %s", s.ReleaseName(), s.Namespace()) return s.Install(ctx, logf, kcli, mcli, hcli, domains, overrides) } From aa3b01c3cbc9bc65bc70827cfbdcf2954d9eb26b Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 14:20:35 -0700 Subject: [PATCH 26/34] remove debug log --- pkg/helm/client.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index bc39abb31a..282dfbbf3d 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -366,8 +366,6 @@ func (h *HelmClient) createValuesFile(values map[string]interface{}) (string, er } func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, error) { - logrus.Debugf("HelmClient.Install: starting install for release '%s' in namespace '%s'", opts.ReleaseName, opts.Namespace) - // Build helm install command arguments args := []string{"install", opts.ReleaseName} From 4eae495ef024b8456e30d4b9bd786839901b2f5b Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 14:21:22 -0700 Subject: [PATCH 27/34] remove debug log --- tests/integration/kind/registry/ha_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/kind/registry/ha_test.go b/tests/integration/kind/registry/ha_test.go index 31c47b0ebe..25d803fd93 100644 --- a/tests/integration/kind/registry/ha_test.go +++ b/tests/integration/kind/registry/ha_test.go @@ -255,7 +255,6 @@ func waitForMatchingMessage(t *testing.T, r io.Reader, re *regexp.Regexp) bool { scanner := bufio.NewScanner(r) for scanner.Scan() { b := scanner.Bytes() - t.Logf("%s got message: %s", formattedTime(), string(b)) if re.Match(b) { t.Logf("%s got matching message: %s", formattedTime(), string(b)) return true From d07ecfaa94b4e86fefc42cd57b2cef2a6210d3ec Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 14:23:56 -0700 Subject: [PATCH 28/34] err msg --- pkg/helm/client.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 282dfbbf3d..0ea4dcdb6c 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -113,7 +113,7 @@ func (h *HelmClient) prepare(ctx context.Context) error { args := []string{"repo", "update", repo.Name} _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("execute command: %w", err) + return fmt.Errorf("helm repo update %s: %w", repo.Name, err) } } return nil @@ -517,30 +517,31 @@ func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, // Execute helm upgrade command stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { + // Check if this is an "another operation in progress" error if isOperationInProgressError(err.Error()) || isOperationInProgressError(stderr) { - // Get the last revision - lastRevision, err := h.GetLastRevision(ctx, opts.Namespace, opts.ReleaseName) - if err != nil { - return "", fmt.Errorf("get last revision: %w", err) + // Get the last revision for rollback + lastRevision, rollbackErr := h.GetLastRevision(ctx, opts.Namespace, opts.ReleaseName) + if rollbackErr != nil { + return "", fmt.Errorf("get last revision for rollback recovery: %w", rollbackErr) } // Rollback to the latest revision - if _, err := h.Rollback(ctx, RollbackOptions{ + if _, rollbackErr := h.Rollback(ctx, RollbackOptions{ ReleaseName: opts.ReleaseName, Namespace: opts.Namespace, Revision: lastRevision, Timeout: opts.Timeout, Force: opts.Force, LogFn: opts.LogFn, - }); err != nil { - return "", fmt.Errorf("rollback: %w", err) + }); rollbackErr != nil { + return "", fmt.Errorf("rollback recovery failed: %w", rollbackErr) } // Retry upgrade after successful rollback return h.Upgrade(ctx, opts) } - return "", fmt.Errorf("execute command: %w", err) + return "", fmt.Errorf("helm upgrade failed: %w", err) } return stdout, nil From 69c69b243ad914bf9f109e73ce65dba6e40b92eb Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 14:28:13 -0700 Subject: [PATCH 29/34] err msgs --- pkg/helm/client.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 0ea4dcdb6c..c75ddc3c91 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -147,7 +147,7 @@ func (h *HelmClient) AddRepo(ctx context.Context, repo *repo.Entry) error { _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("execute command: %w", err) + return fmt.Errorf("helm repo add: %w", err) } // Store the repository entry for future reference @@ -161,7 +161,7 @@ func (h *HelmClient) Latest(ctx context.Context, reponame, chart string) (string stdout, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return "", fmt.Errorf("execute command: %w", err) + return "", fmt.Errorf("helm search repo: %w", err) } // Parse JSON output @@ -223,7 +223,7 @@ func (h *HelmClient) PullByRef(ctx context.Context, ref string, version string) _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return "", fmt.Errorf("execute command: %w", err) + return "", fmt.Errorf("helm pull: %w", err) } // Get chart metadata to determine the actual chart name and construct filename @@ -244,7 +244,7 @@ func (h *HelmClient) RegistryAuth(ctx context.Context, server, user, pass string _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("execute command: %w", err) + return fmt.Errorf("helm registry login: %w", err) } return nil @@ -256,7 +256,7 @@ func (h *HelmClient) Push(ctx context.Context, path, dst string) error { _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("execute command: %w", err) + return fmt.Errorf("helm push: %w", err) } return nil @@ -271,7 +271,7 @@ func (h *HelmClient) GetChartMetadata(ctx context.Context, ref string, version s stdout, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return nil, fmt.Errorf("execute command: %w", err) + return nil, fmt.Errorf("helm show chart: %w", err) } var metadata chart.Metadata @@ -300,7 +300,7 @@ func (h *HelmClient) ReleaseHistory(ctx context.Context, namespace string, relea stdout, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return nil, fmt.Errorf("execute command: %w", err) + return nil, fmt.Errorf("helm history: %w", err) } var history []ReleaseHistoryEntry @@ -427,7 +427,7 @@ func (h *HelmClient) Install(ctx context.Context, opts InstallOptions) (string, // Execute helm install command stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return "", fmt.Errorf("execute command: %w", err) + return "", fmt.Errorf("execute: %w", err) } return stdout, nil @@ -588,7 +588,7 @@ func (h *HelmClient) Rollback(ctx context.Context, opts RollbackOptions) (string stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return "", fmt.Errorf("execute command: %w", err) + return "", fmt.Errorf("execute: %w", err) } return stdout, nil @@ -628,7 +628,7 @@ func (h *HelmClient) Uninstall(ctx context.Context, opts UninstallOptions) error // Execute helm uninstall command _, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return fmt.Errorf("execute command: %w", err) + return fmt.Errorf("execute: %w", err) } return nil @@ -687,7 +687,7 @@ func (h *HelmClient) Render(ctx context.Context, opts InstallOptions) ([][]byte, // Execute helm template command stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return nil, fmt.Errorf("execute command: %w", err) + return nil, fmt.Errorf("execute: %w", err) } manifests, err := splitManifests(stdout) From ae78ea996b580c5d33cdd24b70539452c9c07d51 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 14:30:31 -0700 Subject: [PATCH 30/34] refactor --- pkg/helm/client.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index c75ddc3c91..337ecaa74a 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -517,24 +517,23 @@ func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, // Execute helm upgrade command stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - // Check if this is an "another operation in progress" error if isOperationInProgressError(err.Error()) || isOperationInProgressError(stderr) { - // Get the last revision for rollback - lastRevision, rollbackErr := h.GetLastRevision(ctx, opts.Namespace, opts.ReleaseName) - if rollbackErr != nil { - return "", fmt.Errorf("get last revision for rollback recovery: %w", rollbackErr) + // Get the last revision + lastRevision, err := h.GetLastRevision(ctx, opts.Namespace, opts.ReleaseName) + if err != nil { + return "", fmt.Errorf("get last revision: %w", err) } // Rollback to the latest revision - if _, rollbackErr := h.Rollback(ctx, RollbackOptions{ + if _, err := h.Rollback(ctx, RollbackOptions{ ReleaseName: opts.ReleaseName, Namespace: opts.Namespace, Revision: lastRevision, Timeout: opts.Timeout, Force: opts.Force, LogFn: opts.LogFn, - }); rollbackErr != nil { - return "", fmt.Errorf("rollback recovery failed: %w", rollbackErr) + }); err != nil { + return "", fmt.Errorf("rollback: %w", err) } // Retry upgrade after successful rollback From 65bfda3fa274038c18b723dc8955a1ebe42385d5 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Thu, 4 Sep 2025 14:33:09 -0700 Subject: [PATCH 31/34] rename --- pkg/helm/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 337ecaa74a..adb8a731ac 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -517,7 +517,7 @@ func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, // Execute helm upgrade command stdout, stderr, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - if isOperationInProgressError(err.Error()) || isOperationInProgressError(stderr) { + if shouldRollback(err.Error()) || shouldRollback(stderr) { // Get the last revision lastRevision, err := h.GetLastRevision(ctx, opts.Namespace, opts.ReleaseName) if err != nil { @@ -546,7 +546,7 @@ func (h *HelmClient) Upgrade(ctx context.Context, opts UpgradeOptions) (string, return stdout, nil } -func isOperationInProgressError(err string) bool { +func shouldRollback(err string) bool { return strings.Contains(err, "another operation") && strings.Contains(err, "in progress") } From ffefb6ca517bfc96a0acd4e675f218b5c3addca2 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Fri, 5 Sep 2025 08:20:03 -0700 Subject: [PATCH 32/34] set helm HOME env vars --- pkg/helm/binary_executor.go | 17 ++++++++++---- pkg/helm/binary_executor_test.go | 39 ++++++++++++++++++++++++++++++-- pkg/helm/client.go | 9 +++++++- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/pkg/helm/binary_executor.go b/pkg/helm/binary_executor.go index d74800ae2c..5b90da1519 100644 --- a/pkg/helm/binary_executor.go +++ b/pkg/helm/binary_executor.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "io" + "maps" "regexp" "strings" @@ -19,12 +20,13 @@ type BinaryExecutor interface { // binaryExecutor implements BinaryExecutor using helpers.RunCommandWithOptions type binaryExecutor struct { - bin string // Path to the binary to execute + bin string // Path to the binary to execute + defaultEnv map[string]string // Default environment variables to set for all commands } -// newBinaryExecutor creates a new binaryExecutor with the specified binary path -func newBinaryExecutor(bin string) BinaryExecutor { - return &binaryExecutor{bin: bin} +// newBinaryExecutor creates a new binaryExecutor with the specified binary path and optional default environment +func newBinaryExecutor(bin string, defaultEnv map[string]string) BinaryExecutor { + return &binaryExecutor{bin: bin, defaultEnv: defaultEnv} } // ExecuteCommand runs a command using helpers.RunCommandWithOptions and returns stdout, stderr, and error @@ -32,11 +34,16 @@ func (c *binaryExecutor) ExecuteCommand(ctx context.Context, env map[string]stri var stdout, stderr bytes.Buffer logWriter := &logWriter{logFn: logFn} + // Merge default environment with provided environment (provided env takes precedence) + mergedEnv := make(map[string]string) + maps.Copy(mergedEnv, c.defaultEnv) + maps.Copy(mergedEnv, env) + err := helpers.RunCommandWithOptions(helpers.RunCommandOptions{ Context: ctx, Stdout: &stdout, Stderr: io.MultiWriter(&stderr, logWriter), // Helm uses stderr for debug logging and progress - Env: env, + Env: mergedEnv, }, c.bin, args...) return stdout.String(), stderr.String(), err diff --git a/pkg/helm/binary_executor_test.go b/pkg/helm/binary_executor_test.go index 688c28e81a..dc0d0eaf33 100644 --- a/pkg/helm/binary_executor_test.go +++ b/pkg/helm/binary_executor_test.go @@ -32,7 +32,7 @@ func Test_binaryExecutor_ExecuteCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - executor := newBinaryExecutor(tt.bin) + executor := newBinaryExecutor(tt.bin, nil) stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, nil, tt.args...) if tt.wantErr { @@ -95,7 +95,7 @@ func Test_binaryExecutor_ExecuteCommand_WithLogging(t *testing.T) { logs = append(logs, fmt.Sprintf(format, v...)) } - executor := newBinaryExecutor(tt.bin) + executor := newBinaryExecutor(tt.bin, nil) stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, logFn, tt.args...) if tt.wantErr { @@ -151,6 +151,41 @@ func Test_logWriter_Write(t *testing.T) { assert.Equal(t, 4, n) } +func Test_binaryExecutor_EnvironmentMerging(t *testing.T) { + // Test that default environment is merged with provided environment + defaultEnv := map[string]string{ + "DEFAULT_VAR": "default_value", + "OVERRIDE_ME": "default_override", + } + + executor := newBinaryExecutor("sh", defaultEnv) + + // Create a command that outputs all environment variables containing our test vars + providedEnv := map[string]string{ + "PROVIDED_VAR": "provided_value", + "OVERRIDE_ME": "overridden_value", // This should override the default + } + + // Use a shell command to check if our environment variables are set + stdout, _, err := executor.ExecuteCommand( + t.Context(), + providedEnv, + nil, + "-c", "echo DEFAULT_VAR=$DEFAULT_VAR PROVIDED_VAR=$PROVIDED_VAR OVERRIDE_ME=$OVERRIDE_ME", + ) + + require.NoError(t, err) + + // Verify that: + // 1. Default env var is present + assert.Contains(t, stdout, "DEFAULT_VAR=default_value") + // 2. Provided env var is present + assert.Contains(t, stdout, "PROVIDED_VAR=provided_value") + // 3. Provided env var overrides default + assert.Contains(t, stdout, "OVERRIDE_ME=overridden_value") + assert.NotContains(t, stdout, "OVERRIDE_ME=default_override") +} + func Test_MockBinaryExecutor_ExecuteCommand(t *testing.T) { tests := []struct { name string diff --git a/pkg/helm/client.go b/pkg/helm/client.go index adb8a731ac..02de5aea39 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -37,9 +37,16 @@ func newClient(opts HelmOptions) (*HelmClient, error) { kversion = sv } + // Create helm environment variables for tmpdir isolation + helmEnv := map[string]string{ + "HELM_CACHE_HOME": filepath.Join(tmpdir, ".cache"), + "HELM_CONFIG_HOME": filepath.Join(tmpdir, ".config"), + "HELM_DATA_HOME": filepath.Join(tmpdir, ".local"), + } + return &HelmClient{ helmPath: opts.HelmPath, - executor: newBinaryExecutor(opts.HelmPath), + executor: newBinaryExecutor(opts.HelmPath, helmEnv), tmpdir: tmpdir, kversion: kversion, kubernetesEnvSettings: opts.KubernetesEnvSettings, From dba5fec938c89c0fcdebb223a40942c7581eece9 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Fri, 5 Sep 2025 08:22:28 -0700 Subject: [PATCH 33/34] rename tmp dir --- pkg/helm/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 02de5aea39..272c742cbb 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -23,7 +23,7 @@ import ( var _ Client = (*HelmClient)(nil) func newClient(opts HelmOptions) (*HelmClient, error) { - tmpdir, err := os.MkdirTemp(os.TempDir(), "helm-cache-*") + tmpdir, err := os.MkdirTemp(os.TempDir(), "helm-*") if err != nil { return nil, err } From d458620260a7e01b391a444bb0d13b5855c30321 Mon Sep 17 00:00:00 2001 From: Salah Aldeen Al Saleh Date: Fri, 5 Sep 2025 08:27:19 -0700 Subject: [PATCH 34/34] update comment --- pkg/helm/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/helm/client.go b/pkg/helm/client.go index 272c742cbb..8bd13e0659 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -37,7 +37,7 @@ func newClient(opts HelmOptions) (*HelmClient, error) { kversion = sv } - // Create helm environment variables for tmpdir isolation + // Configure helm environment variables for tmpdir isolation helmEnv := map[string]string{ "HELM_CACHE_HOME": filepath.Join(tmpdir, ".cache"), "HELM_CONFIG_HOME": filepath.Join(tmpdir, ".config"),