diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1604f61dc4..a3a7b12b8d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -157,10 +157,14 @@ 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 uses: actions/checkout@v5 + with: + fetch-depth: 0 # necessary for getting the last tag - name: Setup go uses: actions/setup-go@v5 with: @@ -178,10 +182,14 @@ 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 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/.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/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/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 751b1e5a10..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" - "k8s.io/cli-runtime/pkg/genericclioptions" kyaml "sigs.k8s.io/yaml" ) @@ -48,9 +48,7 @@ type InstallController struct { clusterID string airgapBundle string privateCACertConfigMapName string - k8sVersion string - restClientGetter genericclioptions.RESTClientGetter - kubeConfigPath string + hcli helm.Client } type InstallControllerOption func(*InstallController) @@ -133,21 +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 WithRESTClientGetter(restClientGetter genericclioptions.RESTClientGetter) InstallControllerOption { - return func(c *InstallController) { - c.restClientGetter = restClientGetter - } -} - -func WithKubeConfigPath(kubeConfigPath string) InstallControllerOption { - return func(c *InstallController) { - c.kubeConfigPath = kubeConfigPath + c.hcli = hcli } } @@ -212,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) @@ -228,9 +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.WithRESTClientGetter(controller.restClientGetter), - appinstallmanager.WithKubeConfigPath(controller.kubeConfigPath), + 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 2a06c18a96..430616f4c6 100644 --- a/api/controllers/kubernetes/install/controller.go +++ b/api/controllers/kubernetes/install/controller.go @@ -14,11 +14,11 @@ 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" helmcli "helm.sh/helm/v3/pkg/cli" - "k8s.io/cli-runtime/pkg/genericclioptions" ) type Controller interface { @@ -34,22 +34,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 + kubernetesEnvSettings *helmcli.EnvSettings + hcli helm.Client + 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 } @@ -74,15 +74,15 @@ 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 } } -func WithRESTClientGetter(restClientGetter genericclioptions.RESTClientGetter) InstallControllerOption { +func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) InstallControllerOption { return func(c *InstallController) { - c.restClientGetter = restClientGetter + c.kubernetesEnvSettings = envSettings } } @@ -176,9 +176,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 { @@ -198,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.WithRESTClientGetter(controller.restClientGetter), + 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) @@ -212,13 +211,14 @@ 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), 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 39b7b86449..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" @@ -98,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) @@ -213,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), @@ -220,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) @@ -281,10 +296,17 @@ func TestGetInstallationStatus(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) @@ -411,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) @@ -424,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) @@ -519,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 b6f7a3be5b..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.WithKubeConfigPath(controller.rc.PathToKubeConfig()), + 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 f44266f19e..0f0c2c46fe 100644 --- a/api/integration/app/install/config_test.go +++ b/api/integration/app/install/config_test.go @@ -18,12 +18,14 @@ 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" "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" ) @@ -1048,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 @@ -1068,7 +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 13d6231a00..9acd901375 100644 --- a/api/integration/kubernetes/install/apppreflight_test.go +++ b/api/integration/kubernetes/install/apppreflight_test.go @@ -22,11 +22,11 @@ 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" "github.com/stretchr/testify/require" - "k8s.io/cli-runtime/pkg/genericclioptions" ) // Test the getAppPreflightsStatus endpoint returns app preflights status correctly @@ -73,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) @@ -81,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) @@ -228,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) @@ -237,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) @@ -246,14 +246,14 @@ func TestPostRunAppPreflights(t *testing.T) { InstallTarget: types.InstallTargetKubernetes, Password: "password", KubernetesConfig: types.KubernetesConfig{ - RESTClientGetter: &genericclioptions.ConfigFlags{}, - Installation: mockInstallation, + Installation: mockInstallation, }, ReleaseData: integration.DefaultReleaseData(), }, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -290,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) @@ -299,14 +299,14 @@ func TestPostRunAppPreflights(t *testing.T) { InstallTarget: types.InstallTargetKubernetes, Password: "password", KubernetesConfig: types.KubernetesConfig{ - RESTClientGetter: &genericclioptions.ConfigFlags{}, - Installation: mockInstallation, + Installation: mockInstallation, }, ReleaseData: integration.DefaultReleaseData(), }, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -337,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) @@ -346,14 +346,14 @@ func TestPostRunAppPreflights(t *testing.T) { InstallTarget: types.InstallTargetKubernetes, Password: "password", KubernetesConfig: types.KubernetesConfig{ - RESTClientGetter: &genericclioptions.ConfigFlags{}, - Installation: mockInstallation, + Installation: mockInstallation, }, ReleaseData: integration.DefaultReleaseData(), }, 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 141f6be8fd..b431afe4c3 100644 --- a/api/internal/handlers/kubernetes/kubernetes.go +++ b/api/internal/handlers/kubernetes/kubernetes.go @@ -6,6 +6,7 @@ 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" ) @@ -15,6 +16,7 @@ type Handler struct { installController install.Controller logger logrus.FieldLogger metricsReporter metrics.ReporterInterface + hcli helm.Client } type Option func(*Handler) @@ -37,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, @@ -52,22 +60,17 @@ 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) - 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.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), 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 9ea185f898..2db7e43bb0 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( @@ -181,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()), @@ -205,7 +203,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{} @@ -219,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()), @@ -255,7 +252,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{} @@ -269,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()), @@ -306,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) @@ -433,12 +429,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{} @@ -451,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), @@ -495,7 +490,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{} @@ -508,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 00128cecae..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" - "k8s.io/cli-runtime/pkg/genericclioptions" ) var _ AppInstallManager = &appInstallManager{} @@ -32,17 +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 - 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 } type AppInstallManagerOption func(*appInstallManager) @@ -96,24 +92,6 @@ func WithHelmClient(hcli helm.Client) AppInstallManagerOption { } } -func WithK8sVersion(k8sVersion string) AppInstallManagerOption { - return func(m *appInstallManager) { - m.k8sVersion = k8sVersion - } -} - -func WithKubeConfigPath(path string) AppInstallManagerOption { - return func(m *appInstallManager) { - m.kubeConfigPath = path - } -} - -func WithRESTClientGetter(restClientGetter genericclioptions.RESTClientGetter) AppInstallManagerOption { - return func(m *appInstallManager) { - m.restClientGetter = restClientGetter - } -} - // NewAppInstallManager creates a new AppInstallManager with the provided options func NewAppInstallManager(opts ...AppInstallManagerOption) (*appInstallManager, error) { manager := &appInstallManager{} @@ -122,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 5b856f186f..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,24 +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{ - KubeConfig: m.kubeConfigPath, - RESTClientGetter: m.restClientGetter, - 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 d5a5f7269b..c9e2f629c5 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" @@ -330,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", + }) + require.NoError(t, err) manager, err := NewAppReleaseManager( config, WithReleaseData(releaseData), - WithK8sVersion("v1.33.0"), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -858,7 +864,7 @@ spec: manager, err := NewAppReleaseManager( config, WithReleaseData(releaseData), - WithK8sVersion("1.33.0"), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -1131,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) @@ -2511,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 2cb2d3cb51..d6b8ffa087 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) } @@ -157,16 +168,7 @@ func NewInfraManager(opts ...InfraManagerOption) (*infraManager, error) { } if manager.hcli == nil { - hcli, err := helm.NewClient(helm.HelmOptions{ - RESTClientGetter: manager.restClientGetter, - // 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 d708ca32d0..a858e126b7 100644 --- a/api/internal/managers/kubernetes/infra/manager_test.go +++ b/api/internal/managers/kubernetes/infra/manager_test.go @@ -3,12 +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" ) @@ -16,81 +15,34 @@ 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) - }, - expectError: false, + name: "fails when helm client not provided", + expectError: true, }, { - 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 only 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) - }, - 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) - }, - withKubeClient: true, - expectError: false, - }, - { - name: "creates only helm client when kube and metadata clients provided", - setupMock: func(mock *clients.MockRESTClientGetter) { - // No ToRESTConfig calls expected - }, - 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 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 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: "uses all provided clients when all are given", withKubeClient: true, withMetadataClient: true, withHelmClient: true, @@ -100,13 +52,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 @@ -117,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 @@ -133,88 +87,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) - }) - } -} - -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{ - WithRESTClientGetter(mockRestClientGetter), - } - - // 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/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 6c5019e970..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.PathToKubeConfig(), 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 d4f7bb8494..a9c1d8e601 100644 --- a/api/internal/managers/linux/infra/util.go +++ b/api/internal/managers/linux/infra/util.go @@ -8,9 +8,9 @@ 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" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + "k8s.io/cli-runtime/pkg/genericclioptions" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -28,9 +28,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(rc runtimeconfig.RuntimeConfig) error { + var restClientGetter genericclioptions.RESTClientGetter + if rc.GetKubernetesEnvSettings() != nil { + restClientGetter = rc.GetKubernetesEnvSettings().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 +43,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) } @@ -46,20 +51,7 @@ func (m *infraManager) setupClients(kubeConfigPath string, airgapChartsPath stri } if m.hcli == nil { - airgapPath := "" - if m.airgapBundle != "" { - airgapPath = airgapChartsPath - } - hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: kubeConfigPath, - 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/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..65e4d00726 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, ver) 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 := 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 { @@ -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/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 80d04a64ee..89f25c8c5a 100644 --- a/cmd/installer/cli/enable_ha.go +++ b/cmd/installer/cli/enable_ha.go @@ -80,9 +80,10 @@ func runEnableHA(ctx context.Context, rc runtimeconfig.RuntimeConfig) error { } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), + 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..454d7937eb 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -52,7 +52,6 @@ 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" "sigs.k8s.io/controller-runtime/pkg/client" @@ -99,8 +98,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. @@ -319,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 } @@ -577,7 +555,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") @@ -605,7 +583,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 +695,7 @@ func runManagerExperienceInstall( AllowIgnoreHostPreflights: flags.ignoreHostPreflights, }, KubernetesConfig: apitypes.KubernetesConfig{ - RESTClientGetter: flags.kubernetesRESTClientGetter, - Installation: ki, + Installation: ki, }, }, @@ -802,9 +779,10 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), + 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..71c475b03e 100644 --- a/cmd/installer/cli/join.go +++ b/cmd/installer/cli/join.go @@ -610,9 +610,10 @@ 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, + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), + 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..c52ccd1157 100644 --- a/cmd/installer/cli/restore.go +++ b/cmd/installer/cli/restore.go @@ -405,9 +405,10 @@ func runRestoreStepNew(ctx context.Context, appSlug, appTitle string, flags Inst } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), + KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), + K8sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, }) if err != nil { return fmt.Errorf("unable to create helm client: %w", err) @@ -612,9 +613,10 @@ func runRestoreEnableAdminConsoleHA(ctx context.Context, flags InstallCmdFlags, } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), + KubernetesEnvSettings: rc.GetKubernetesEnvSettings(), + K8sVersion: versions.K0sVersion, + AirgapPath: airgapChartsPath, }) if err != nil { return fmt.Errorf("create helm client: %w", err) @@ -710,9 +712,10 @@ func runRestoreExtensions(ctx context.Context, flags InstallCmdFlags, rc runtime } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K8sVersion: versions.K0sVersion, - AirgapPath: airgapChartsPath, + HelmPath: rc.PathToEmbeddedClusterBinary("helm"), + 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/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..ec44b98609 100644 --- a/operator/pkg/cli/upgrade_job.go +++ b/operator/pkg/cli/upgrade_job.go @@ -60,11 +60,9 @@ 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{}) { - 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-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/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/integration/hostcabundle_test.go b/pkg/addons/adminconsole/integration/hostcabundle_test.go index 4c5e6ff6f0..3e731b23aa 100644 --- a/pkg/addons/adminconsole/integration/hostcabundle_test.go +++ b/pkg/addons/adminconsole/integration/hostcabundle_test.go @@ -27,7 +27,10 @@ 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 + K8sVersion: "v1.26.0", + }) 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..a503196cf5 100644 --- a/pkg/addons/adminconsole/integration/kubernetes_test.go +++ b/pkg/addons/adminconsole/integration/kubernetes_test.go @@ -31,7 +31,10 @@ 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 + K8sVersion: "v1.26.0", + }) 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..70fe6b594a 100644 --- a/pkg/addons/adminconsole/integration/linux_test.go +++ b/pkg/addons/adminconsole/integration/linux_test.go @@ -45,7 +45,10 @@ 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 + K8sVersion: "v1.26.0", + }) 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/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/integration/hostcabundle_test.go b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go index 2ee9a572ef..8ab4dac29f 100644 --- a/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go +++ b/pkg/addons/embeddedclusteroperator/integration/hostcabundle_test.go @@ -28,7 +28,10 @@ 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 + K8sVersion: "v1.26.0", + }) 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/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/integration/hostcabundle_test.go b/pkg/addons/velero/integration/hostcabundle_test.go index 3a0056472a..be19c27e0a 100644 --- a/pkg/addons/velero/integration/hostcabundle_test.go +++ b/pkg/addons/velero/integration/hostcabundle_test.go @@ -22,7 +22,10 @@ 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 + K8sVersion: "v1.26.0", + }) 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..79755ccb1c 100644 --- a/pkg/addons/velero/integration/k0ssubdir_test.go +++ b/pkg/addons/velero/integration/k0ssubdir_test.go @@ -24,7 +24,10 @@ 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 + K8sVersion: "v1.26.0", + }) 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/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/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..5b90da1519 --- /dev/null +++ b/pkg/helm/binary_executor.go @@ -0,0 +1,68 @@ +package helm + +import ( + "bytes" + "context" + "io" + "maps" + "regexp" + "strings" + + "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, 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 + defaultEnv map[string]string // Default environment variables to set for all commands +} + +// 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 +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: 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: mergedEnv, + }, c.bin, args...) + + return stdout.String(), stderr.String(), err +} + +// logWriter wraps a logFn as an io.Writer +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 { + line := strings.TrimSpace(string(p)) + if line != "" && goFilePattern.MatchString(line) { + lw.logFn("helm: %s", line) + } + } + 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..0453f064e4 --- /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, 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 new file mode 100644 index 0000000000..dc0d0eaf33 --- /dev/null +++ b/pkg/helm/binary_executor_test.go @@ -0,0 +1,252 @@ +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, 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{}, // 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{"helm: install.go:225: debug message"}, // Go file pattern should be logged with helm prefix + }, + } + + 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, nil) + stdout, stderr, err := executor.ExecuteCommand(t.Context(), nil, logFn, 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, fmt.Sprintf(format, v...)) + } + + writer := &logWriter{logFn: logFn} + + // 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, 28, n) + assert.Len(t, loggedMessages, 1) + 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 + 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{}) + 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_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 + 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"}, + mock.Anything, // LogFn + []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, + mock.Anything, // LogFn + []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, nil, 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..8bd13e0659 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -1,11 +1,9 @@ package helm import ( - "bytes" "context" - "errors" + "encoding/json" "fmt" - "io" "os" "path/filepath" "strings" @@ -13,61 +11,23 @@ import ( "github.com/Masterminds/semver/v3" "github.com/sirupsen/logrus" + "github.com/spf13/pflag" "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) { - tmpdir, err := os.MkdirTemp(os.TempDir(), "helm-cache-*") + tmpdir, err := os.MkdirTemp(os.TempDir(), "helm-*") if err != nil { return nil, err } - registryOpts := []registry.ClientOption{} - if opts.Writer != nil { - registryOpts = append(registryOpts, registry.ClientOptWriter(opts.Writer)) - } + var kversion *semver.Version if opts.K8sVersion != "" { sv, err := semver.NewVersion(opts.K8sVersion) @@ -76,36 +36,34 @@ 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 + + // 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"), + "HELM_DATA_HOME": filepath.Join(tmpdir, ".local"), } + return &HelmClient{ - tmpdir: tmpdir, - kversion: kversion, - restClientGetter: opts.RESTClientGetter, - regcli: regcli, - logFn: opts.LogFn, - airgapPath: opts.AirgapPath, + helmPath: opts.HelmPath, + executor: newBinaryExecutor(opts.HelmPath, helmEnv), + 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 // Required: Path to the helm binary + KubernetesEnvSettings *helmcli.EnvSettings + K8sVersion string + AirgapPath string } +type LogFn func(format string, args ...interface{}) + type InstallOptions struct { ReleaseName string ChartPath string @@ -114,6 +72,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 { @@ -125,6 +84,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 { @@ -132,51 +92,37 @@ type UninstallOptions struct { Namespace string Wait bool IgnoreNotFound bool + LogFn LogFn // Log function override to use for uninstall command } -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 +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 } -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() +type HelmClient struct { + 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} + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return fmt.Errorf("download index file: %w", err) + return fmt.Errorf("helm repo update %s: %w", repo.Name, err) } } - h.repocfg = repocfg - h.reposChanged = false return nil } @@ -184,65 +130,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) - } + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + if err != nil { + return fmt.Errorf("helm repo add: %w", err) + } - 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, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + if err != nil { + return "", fmt.Errorf("helm search repo: %w", err) + } + + // Parse JSON output + var results []struct { + Version string `json:"version"` + } + if err := json.Unmarshal([]byte(stdout), &results); err != nil { + return "", fmt.Errorf("parse helm search json output: %w", err) + } - return "", fmt.Errorf("no stable version found for chart %s", chart) + if len(results) == 0 { + return "", fmt.Errorf("no charts found for %s/%s", reponame, chart) } - return "", fmt.Errorf("repository %s not found", reponame) + + // 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 +205,570 @@ 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") - dst, _, err := dl.DownloadTo(ref, version, os.TempDir()) + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return "", fmt.Errorf("download chart %s: %w", ref, err) + return "", fmt.Errorf("helm pull: %w", err) } - return dst, nil + // Get chart metadata to determine the actual chart name and construct filename + metadata, err := h.GetChartMetadata(ctx, ref, version) + if err != nil { + return "", fmt.Errorf("get chart metadata: %w", err) + } + + // 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} + + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + if err != nil { + return fmt.Errorf("helm registry login: %w", err) + } + + 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} + + _, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) + if err != nil { + return fmt.Errorf("helm push: %w", err) } - 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, 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, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return nil, fmt.Errorf("load chart: %w", err) + return nil, fmt.Errorf("helm show chart: %w", err) } - 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) +// 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) + + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, nil, args...) if err != nil { - return false, fmt.Errorf("get action configuration: %w", err) + return nil, fmt.Errorf("helm history: %w", err) + } + + var history []ReleaseHistoryEntry + if err := json.Unmarshal([]byte(stdout), &history); err != nil { + return nil, fmt.Errorf("parse release history json: %w", err) } - client := action.NewHistory(cfg) - client.Max = 1 + return history, nil +} - versions, err := client.Run(releaseName) - if errors.Is(err, driver.ErrReleaseNotFound) || isReleaseUninstalled(versions) { - return false, 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 { + if strings.Contains(err.Error(), "release: not found") { + return false, nil + } return false, fmt.Errorf("get release history: %w", err) } - return true, nil -} + // True if release has history and is not uninstalled + exists := len(history) > 0 && history[0].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()) + + // Add replace flag + args = append(args, "--replace") - 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, 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, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return nil, fmt.Errorf("helm install: %w", err) + return "", fmt.Errorf("execute: %w", err) } - 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) + + // Add namespace + if opts.Namespace != "" { + args = append(args, "--namespace", opts.Namespace) } - 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 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) + // 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, ",")) + } + + // 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, opts.LogFn, args...) if err != nil { - return nil, fmt.Errorf("clean up generic map: %w", err) + if shouldRollback(err.Error()) || shouldRollback(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) + } + + // 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("helm upgrade failed: %w", err) + } + + return stdout, nil +} + +func shouldRollback(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} + + // 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") } - 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") + + stdout, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) if err != nil { - return nil, fmt.Errorf("helm upgrade: %w", err) + return "", fmt.Errorf("execute: %w", err) } - 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) } - client := action.NewUninstall(cfg) - client.Wait = opts.Wait - client.IgnoreNotFound = opts.IgnoreNotFound + // Add wait flag + if opts.Wait { + args = append(args, "--wait") + } + + // Add ignore not found flag + if opts.IgnoreNotFound { + args = append(args, "--ignore-not-found") + } + // 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 + _, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + if err != nil { + return fmt.Errorf("execute: %w", err) } 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) + args = append(args, "--create-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, _, err := h.executor.ExecuteCommand(ctx, nil, opts.LogFn, args...) + if err != nil { + return nil, fmt.Errorf("execute: %w", err) } - 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 below + 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 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 - return chartRequested, nil + 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) { @@ -568,45 +789,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..7b87e2c2ff 100644 --- a/pkg/helm/client_test.go +++ b/pkg/helm/client_test.go @@ -1,12 +1,583 @@ package helm import ( + "fmt" + "os" + "strings" "testing" + "time" + "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" ) +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 + mock.Anything, // LogFn + []string{"repo", "update", "myrepo"}, + ).Return("", "", nil) + + // Mock helm pull command + 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" && + 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 + mock.Anything, // LogFn + []string{"show", "chart", "myrepo/mychart", "--version", "1.2.3"}, + ).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.Anything, // LogFn + 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 + mock.Anything, // LogFn + []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 +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) + kubernetesEnvSettings *helmcli.EnvSettings + opts InstallOptions + wantErr bool + }{ + { + name: "successful install", + setupMock: func(m *MockBinaryExecutor) { + 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) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + 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.Anything, // LogFn + mock.MatchedBy(func(args []string) bool { + 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", + Namespace: "default", + Timeout: 5 * time.Minute, + Values: map[string]interface{}{ + "key": "value", + }, + }, + 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 { + 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, + } + + 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) + kubernetesEnvSettings *helmcli.EnvSettings + namespace string + releaseName string + want bool + wantErr bool + }{ + { + name: "release exists", + 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") && + strings.Contains(argsStr, "--kubeconfig /tmp/test-kubeconfig") + }), + ).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: &helmcli.EnvSettings{ + KubeConfig: "/tmp/test-kubeconfig", + }, + 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 + mock.Anything, // LogFn + []string{"history", "myrelease", "--namespace", "default", "--output", "json", "--max", "1"}, + ).Return(`[]`, "", nil) + }, + kubernetesEnvSettings: nil, // No kubeconfig settings + namespace: "default", + releaseName: "myrelease", + 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 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, + }, + { + name: "release exists 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, "--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(`[{ + "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: &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, + }, + } + + 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, + } + + 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 + version string + wantErr bool + }{ + { + name: "successful metadata retrieval", + setupMock: func(m *MockBinaryExecutor) { + 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 +description: A test chart +type: application +version: 1.0.0 +appVersion: "1.0.0"`, "", nil) + }, + chartPath: "/path/to/chart", + version: "1.0.0", + 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, tt.version) + + 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 +732,782 @@ 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, 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, 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, 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, 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) + }) + } +} + +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, + }, + { + 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 { + 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") && + 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", + 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", + 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) + }) + } +} +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) + }) + } +} diff --git a/pkg/helm/images.go b/pkg/helm/images.go index 94f9d8446d..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(hcli Client, ref string, version string) (*chart.Metadata, error) { - chartPath, err := hcli.PullByRef(ref, version) - if err != nil { - return nil, fmt.Errorf("pull: %w", err) - } - defer os.RemoveAll(chartPath) - - return hcli.GetChartMetadata(chartPath) -} - func extractImagesFromK8sManifest(resource []byte) ([]string, error) { images := []string{} diff --git a/pkg/helm/interface.go b/pkg/helm/interface.go index 5f90ba4aea..e1ce8e85d8 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, version 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..c9d907f705 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, version string) (*chart.Metadata, error) { + args := m.Called(ctx, chartPath, version) 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/helpers/command.go b/pkg/helpers/command.go index 8d76079b3d..5ed456b9e9 100644 --- a/pkg/helpers/command.go +++ b/pkg/helpers/command.go @@ -23,26 +23,36 @@ func (h *Helpers) RunCommandWithOptions(opts RunCommandOptions, bin string, args stderr := bytes.NewBuffer(nil) stdout := bytes.NewBuffer(nil) cmd := exec.CommandContext(ctx, bin, args...) + 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 err := cmd.Run(); err != nil { 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()) } 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..1e82a7d3fe 100644 --- a/tests/integration/util/helm.go +++ b/tests/integration/util/helm.go @@ -5,10 +5,18 @@ 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{ + 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/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 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..a2e2db2b71 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 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' + ]; + + 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}
))}