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 c943295226..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,8 +95,19 @@ 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 == "" { + return nil, fmt.Errorf("target is required") + } + api := &API{ cfg: cfg, } @@ -115,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 71359ec132..ed8f38a6a1 100644 --- a/api/controllers/app/install/controller.go +++ b/api/controllers/app/install/controller.go @@ -13,6 +13,7 @@ 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" @@ -27,7 +28,7 @@ type Controller interface { GetAppPreflightStatus(ctx context.Context) (types.Status, error) GetAppPreflightOutput(ctx context.Context) (*types.PreflightsOutput, error) GetAppPreflightTitles(ctx context.Context) ([]string, error) - InstallApp(ctx context.Context, ignoreAppPreflights bool) error + InstallApp(ctx context.Context, opts InstallAppOptions) error GetAppInstallStatus(ctx context.Context) (types.AppInstall, error) } @@ -47,6 +48,7 @@ type InstallController struct { clusterID string airgapBundle string privateCACertConfigMapName string + hcli helm.Client } type InstallControllerOption func(*InstallController) @@ -129,6 +131,12 @@ func WithPrivateCACertConfigMapName(configMapName string) InstallControllerOptio } } +func WithHelmClient(hcli helm.Client) InstallControllerOption { + return func(c *InstallController) { + c.hcli = hcli + } +} + func NewInstallController(opts ...InstallControllerOption) (*InstallController, error) { controller := &InstallController{ logger: logger.NewDiscardLogger(), @@ -190,6 +198,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appreleasemanager.WithReleaseData(controller.releaseData), appreleasemanager.WithLicense(license), appreleasemanager.WithPrivateCACertConfigMapName(controller.privateCACertConfigMapName), + appreleasemanager.WithHelmClient(controller.hcli), ) if err != nil { return nil, fmt.Errorf("create app release manager: %w", err) @@ -205,6 +214,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appinstallmanager.WithClusterID(controller.clusterID), appinstallmanager.WithAirgapBundle(controller.airgapBundle), appinstallmanager.WithAppInstallStore(controller.store.AppInstallStore()), + appinstallmanager.WithHelmClient(controller.hcli), ) if err != nil { return nil, fmt.Errorf("create app install manager: %w", err) diff --git a/api/controllers/app/install/controller_mock.go b/api/controllers/app/install/controller_mock.go index c1b5f5389e..bd6eb7df3a 100644 --- a/api/controllers/app/install/controller_mock.go +++ b/api/controllers/app/install/controller_mock.go @@ -69,8 +69,8 @@ func (m *MockController) GetAppPreflightTitles(ctx context.Context) ([]string, e } // InstallApp mocks the InstallApp method -func (m *MockController) InstallApp(ctx context.Context, ignoreAppPreflights bool) error { - args := m.Called(ctx, ignoreAppPreflights) +func (m *MockController) InstallApp(ctx context.Context, opts InstallAppOptions) error { + args := m.Called(ctx, opts) return args.Error(0) } diff --git a/api/controllers/app/install/install.go b/api/controllers/app/install/install.go index d2bb177398..4f0e73e5d6 100644 --- a/api/controllers/app/install/install.go +++ b/api/controllers/app/install/install.go @@ -8,14 +8,21 @@ import ( states "github.com/replicatedhq/embedded-cluster/api/internal/states/install" "github.com/replicatedhq/embedded-cluster/api/types" + ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" ) var ( ErrAppPreflightChecksFailed = errors.New("app preflight checks failed") ) +type InstallAppOptions struct { + IgnoreAppPreflights bool + ProxySpec *ecv1beta1.ProxySpec + RegistrySettings *types.RegistrySettings +} + // InstallApp triggers app installation with proper state transitions and panic handling -func (c *InstallController) InstallApp(ctx context.Context, ignoreAppPreflights bool) (finalErr error) { +func (c *InstallController) InstallApp(ctx context.Context, opts InstallAppOptions) (finalErr error) { lock, err := c.stateMachine.AcquireLock() if err != nil { return types.NewConflictError(err) @@ -33,7 +40,7 @@ func (c *InstallController) InstallApp(ctx context.Context, ignoreAppPreflights // Check if app preflights have failed and if we should ignore them if c.stateMachine.CurrentState() == states.StateAppPreflightsFailed { allowIgnoreAppPreflights := true // TODO: implement once we check for strict app preflights - if !ignoreAppPreflights || !allowIgnoreAppPreflights { + if !opts.IgnoreAppPreflights || !allowIgnoreAppPreflights { return types.NewBadRequestError(ErrAppPreflightChecksFailed) } err = c.stateMachine.Transition(lock, states.StateAppPreflightsFailedBypassed) @@ -47,9 +54,15 @@ func (c *InstallController) InstallApp(ctx context.Context, ignoreAppPreflights } // Get config values for app installation - configValues, err := c.appConfigManager.GetKotsadmConfigValues() + appConfigValues, err := c.GetAppConfigValues(ctx) + if err != nil { + return fmt.Errorf("get app config values for app install: %w", err) + } + + // Get KOTS config values for the KOTS CLI + kotsConfigValues, err := c.appConfigManager.GetKotsadmConfigValues() if err != nil { - return fmt.Errorf("get kotsadm config values for app install: %w", err) + return fmt.Errorf("get kots config values for app install: %w", err) } err = c.stateMachine.Transition(lock, states.StateAppInstalling) @@ -80,8 +93,14 @@ func (c *InstallController) InstallApp(ctx context.Context, ignoreAppPreflights } }() - // Install the app - err := c.appInstallManager.Install(ctx, configValues) + // Extract installable Helm charts from release manager + installableCharts, err := c.appReleaseManager.ExtractInstallableHelmCharts(ctx, appConfigValues, opts.ProxySpec, opts.RegistrySettings) + if err != nil { + return fmt.Errorf("extract installable helm charts: %w", err) + } + + // Install the app with installable charts and kots config values + err = c.appInstallManager.Install(ctx, installableCharts, kotsConfigValues) if err != nil { return fmt.Errorf("install app: %w", err) } diff --git a/api/controllers/app/install/test_suite.go b/api/controllers/app/install/test_suite.go index b91bc8bdfa..df578001d7 100644 --- a/api/controllers/app/install/test_suite.go +++ b/api/controllers/app/install/test_suite.go @@ -13,6 +13,8 @@ import ( states "github.com/replicatedhq/embedded-cluster/api/internal/states/install" "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" @@ -131,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( @@ -141,6 +144,7 @@ func (s *AppInstallControllerTestSuite) TestPatchAppConfigValues() { WithAppInstallManager(appInstallManager), WithStore(&store.MockStore{}), WithReleaseData(&release.ReleaseData{}), + WithHelmClient(mockHelmClient), ) require.NoError(t, err, "failed to create install controller") @@ -400,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), @@ -408,6 +413,7 @@ func (s *AppInstallControllerTestSuite) TestRunAppPreflights() { WithAppReleaseManager(appReleaseManager), WithStore(&store.MockStore{}), WithReleaseData(&release.ReleaseData{}), + WithHelmClient(mockHelmClient), ) require.NoError(t, err, "failed to create install controller") @@ -470,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( @@ -480,6 +487,7 @@ func (s *AppInstallControllerTestSuite) TestGetAppInstallStatus() { WithAppInstallManager(appInstallManager), WithStore(&store.MockStore{}), WithReleaseData(&release.ReleaseData{}), + WithHelmClient(mockHelmClient), ) require.NoError(t, err, "failed to create install controller") @@ -503,16 +511,18 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { tests := []struct { name string ignoreAppPreflights bool + proxySpec *ecv1beta1.ProxySpec + registrySettings *types.RegistrySettings currentState statemachine.State expectedState statemachine.State - setupMocks func(*appconfig.MockAppConfigManager, *appinstallmanager.MockAppInstallManager) + setupMocks func(*appconfig.MockAppConfigManager, *appreleasemanager.MockAppReleaseManager, *appinstallmanager.MockAppInstallManager) expectedErr bool }{ { name: "invalid state transition from succeeded state", currentState: states.StateSucceeded, expectedState: states.StateSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager) { // No mocks needed for invalid state transition }, expectedErr: true, @@ -521,27 +531,37 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { name: "invalid state transition from infrastructure installing state", currentState: states.StateInfrastructureInstalling, expectedState: states.StateInfrastructureInstalling, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager) { // No mocks needed for invalid state transition }, expectedErr: true, }, { - name: "successful app installation from app preflights succeeded state", + name: "successful app installation from app preflights succeeded state with helm charts", currentState: states.StateAppPreflightsSucceeded, expectedState: states.StateSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager) { - mock.InOrder( - acm.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "test-key": {Value: "test-value"}, - }, + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager) { + configValues := kotsv1beta1.ConfigValues{ + Spec: kotsv1beta1.ConfigValuesSpec{ + Values: map[string]kotsv1beta1.ConfigValue{ + "test-key": {Value: "test-value"}, }, - }, nil), - aim.On("Install", mock.Anything, mock.MatchedBy(func(cv kotsv1beta1.ConfigValues) bool { - return cv.Spec.Values["test-key"].Value == "test-value" - })).Return(nil), + }, + } + expectedCharts := []types.InstallableHelmChart{ + { + Archive: []byte("chart-archive-data"), + Values: map[string]any{"key": "value"}, + }, + } + appConfigValues := types.AppConfigValues{ + "test-key": types.AppConfigValue{Value: "test-value"}, + } + mock.InOrder( + acm.On("GetConfigValues").Return(appConfigValues, nil), + acm.On("GetKotsadmConfigValues").Return(configValues, nil), + arm.On("ExtractInstallableHelmCharts", mock.Anything, appConfigValues, mock.AnythingOfType("*v1beta1.ProxySpec"), mock.AnythingOfType("*types.RegistrySettings")).Return(expectedCharts, nil), + aim.On("Install", mock.Anything, expectedCharts, configValues).Return(nil), ) }, expectedErr: false, @@ -550,18 +570,22 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { name: "successful app installation from app preflights failed bypassed state", currentState: states.StateAppPreflightsFailedBypassed, expectedState: states.StateSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager) { - mock.InOrder( - acm.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "test-key": {Value: "test-value"}, - }, + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager) { + configValues := kotsv1beta1.ConfigValues{ + Spec: kotsv1beta1.ConfigValuesSpec{ + Values: map[string]kotsv1beta1.ConfigValue{ + "test-key": {Value: "test-value"}, }, - }, nil), - aim.On("Install", mock.Anything, mock.MatchedBy(func(cv kotsv1beta1.ConfigValues) bool { - return cv.Spec.Values["test-key"].Value == "test-value" - })).Return(nil), + }, + } + appConfigValues := types.AppConfigValues{ + "test-key": types.AppConfigValue{Value: "test-value"}, + } + mock.InOrder( + acm.On("GetConfigValues").Return(appConfigValues, nil), + acm.On("GetKotsadmConfigValues").Return(configValues, nil), + arm.On("ExtractInstallableHelmCharts", mock.Anything, appConfigValues, mock.AnythingOfType("*v1beta1.ProxySpec"), mock.AnythingOfType("*types.RegistrySettings")).Return([]types.InstallableHelmChart{}, nil), + aim.On("Install", mock.Anything, []types.InstallableHelmChart{}, configValues).Return(nil), ) }, expectedErr: false, @@ -570,7 +594,11 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { name: "get config values error", currentState: states.StateAppPreflightsSucceeded, expectedState: states.StateAppPreflightsSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager) { + appConfigValues := types.AppConfigValues{ + "test-key": types.AppConfigValue{Value: "test-value"}, + } + acm.On("GetConfigValues").Return(appConfigValues, nil) acm.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{}, errors.New("config values error")) }, expectedErr: true, @@ -580,18 +608,22 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { ignoreAppPreflights: true, currentState: states.StateAppPreflightsFailed, expectedState: states.StateSucceeded, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager) { - mock.InOrder( - acm.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{ - Spec: kotsv1beta1.ConfigValuesSpec{ - Values: map[string]kotsv1beta1.ConfigValue{ - "test-key": {Value: "test-value"}, - }, + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager) { + configValues := kotsv1beta1.ConfigValues{ + Spec: kotsv1beta1.ConfigValuesSpec{ + Values: map[string]kotsv1beta1.ConfigValue{ + "test-key": {Value: "test-value"}, }, - }, nil), - aim.On("Install", mock.Anything, mock.MatchedBy(func(cv kotsv1beta1.ConfigValues) bool { - return cv.Spec.Values["test-key"].Value == "test-value" - })).Return(nil), + }, + } + appConfigValues := types.AppConfigValues{ + "test-key": types.AppConfigValue{Value: "test-value"}, + } + mock.InOrder( + acm.On("GetConfigValues").Return(appConfigValues, nil), + acm.On("GetKotsadmConfigValues").Return(configValues, nil), + arm.On("ExtractInstallableHelmCharts", mock.Anything, appConfigValues, mock.AnythingOfType("*v1beta1.ProxySpec"), mock.AnythingOfType("*types.RegistrySettings")).Return([]types.InstallableHelmChart{}, nil), + aim.On("Install", mock.Anything, []types.InstallableHelmChart{}, configValues).Return(nil), ) }, expectedErr: false, @@ -601,11 +633,54 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { ignoreAppPreflights: false, currentState: states.StateAppPreflightsFailed, expectedState: states.StateAppPreflightsFailed, - setupMocks: func(acm *appconfig.MockAppConfigManager, aim *appinstallmanager.MockAppInstallManager) { + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager) { // No mocks needed as method should return early with error }, expectedErr: true, }, + { + name: "successful app installation with proxy spec passed to helm chart extraction", + currentState: states.StateAppPreflightsSucceeded, + expectedState: states.StateSucceeded, + proxySpec: &ecv1beta1.ProxySpec{ + HTTPProxy: "http://proxy.example.com:8080", + HTTPSProxy: "https://proxy.example.com:8080", + NoProxy: "localhost,127.0.0.1", + }, + registrySettings: &types.RegistrySettings{ + HasLocalRegistry: true, + LocalRegistryHost: "10.128.0.11:5000", + }, + setupMocks: func(acm *appconfig.MockAppConfigManager, arm *appreleasemanager.MockAppReleaseManager, aim *appinstallmanager.MockAppInstallManager) { + configValues := kotsv1beta1.ConfigValues{ + Spec: kotsv1beta1.ConfigValuesSpec{ + Values: map[string]kotsv1beta1.ConfigValue{ + "test-key": {Value: "test-value"}, + }, + }, + } + appConfigValues := types.AppConfigValues{ + "test-key": types.AppConfigValue{Value: "test-value"}, + } + expectedCharts := []types.InstallableHelmChart{ + { + Archive: []byte("chart-with-proxy-template"), + Values: map[string]any{"proxy_url": "http://proxy.example.com:8080"}, + }, + } + mock.InOrder( + acm.On("GetConfigValues").Return(appConfigValues, nil), + acm.On("GetKotsadmConfigValues").Return(configValues, nil), + arm.On("ExtractInstallableHelmCharts", mock.Anything, appConfigValues, mock.MatchedBy(func(proxySpec *ecv1beta1.ProxySpec) bool { + return proxySpec != nil + }), mock.MatchedBy(func(registrySettings *types.RegistrySettings) bool { + return registrySettings != nil + })).Return(expectedCharts, nil), + aim.On("Install", mock.Anything, expectedCharts, configValues).Return(nil), + ) + }, + expectedErr: false, + }, } for _, tt := range tests { @@ -614,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( @@ -624,11 +700,16 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { WithAppInstallManager(appInstallManager), WithStore(&store.MockStore{}), WithReleaseData(&release.ReleaseData{}), + WithHelmClient(mockHelmClient), ) require.NoError(t, err, "failed to create install controller") - tt.setupMocks(appConfigManager, appInstallManager) - err = controller.InstallApp(t.Context(), tt.ignoreAppPreflights) + tt.setupMocks(appConfigManager, appReleaseManager, appInstallManager) + err = controller.InstallApp(t.Context(), InstallAppOptions{ + IgnoreAppPreflights: tt.ignoreAppPreflights, + ProxySpec: tt.proxySpec, + RegistrySettings: tt.registrySettings, + }) if tt.expectedErr { assert.Error(t, err) @@ -643,6 +724,7 @@ func (s *AppInstallControllerTestSuite) TestInstallApp() { assert.False(t, sm.IsLockAcquired(), "state machine should not be locked after app installation") appConfigManager.AssertExpectations(s.T()) + appReleaseManager.AssertExpectations(s.T()) appInstallManager.AssertExpectations(s.T()) }) } diff --git a/api/controllers/kubernetes/install/controller.go b/api/controllers/kubernetes/install/controller.go index ebee11fa04..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,21 +34,22 @@ type Controller interface { var _ Controller = (*InstallController)(nil) type InstallController struct { - installationManager installation.InstallationManager - infraManager infra.InfraManager - metricsReporter metrics.ReporterInterface - 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 } @@ -73,9 +74,15 @@ func WithMetricsReporter(metricsReporter metrics.ReporterInterface) InstallContr } } -func WithRESTClientGetter(restClientGetter genericclioptions.RESTClientGetter) InstallControllerOption { +func WithHelmClient(hcli helm.Client) InstallControllerOption { return func(c *InstallController) { - c.restClientGetter = restClientGetter + c.hcli = hcli + } +} + +func WithKubernetesEnvSettings(envSettings *helmcli.EnvSettings) InstallControllerOption { + return func(c *InstallController) { + c.kubernetesEnvSettings = envSettings } } @@ -169,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 { @@ -192,6 +199,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, appcontroller.WithConfigValues(controller.configValues), appcontroller.WithAirgapBundle(controller.airgapBundle), 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) @@ -203,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_mock.go b/api/controllers/kubernetes/install/controller_mock.go index 25e1a744d3..158512ecfd 100644 --- a/api/controllers/kubernetes/install/controller_mock.go +++ b/api/controllers/kubernetes/install/controller_mock.go @@ -109,8 +109,8 @@ func (m *MockController) RunAppPreflights(ctx context.Context, opts appcontrolle } // InstallApp mocks the InstallApp method -func (m *MockController) InstallApp(ctx context.Context, ignoreAppPreflights bool) error { - args := m.Called(ctx, ignoreAppPreflights) +func (m *MockController) InstallApp(ctx context.Context, opts appcontroller.InstallAppOptions) error { + args := m.Called(ctx, opts) return args.Error(0) } diff --git a/api/controllers/kubernetes/install/controller_test.go b/api/controllers/kubernetes/install/controller_test.go index 88312334f2..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,10 +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{})), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -212,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), @@ -219,6 +235,7 @@ func TestConfigureInstallation(t *testing.T) { WithStore(mockStore), WithMetricsReporter(metricsReporter), WithReleaseData(getTestReleaseData(&kotsv1beta1.Config{})), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -279,9 +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{})), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -408,6 +433,7 @@ func TestSetupInfra(t *testing.T) { appcontroller.WithStore(mockStore), appcontroller.WithReleaseData(getTestReleaseData(&appConfig)), appcontroller.WithAppConfigManager(mockAppConfigManager), + appcontroller.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -420,6 +446,7 @@ func TestSetupInfra(t *testing.T) { WithMetricsReporter(mockMetricsReporter), WithReleaseData(getTestReleaseData(&appConfig)), WithStore(mockStore), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -514,6 +541,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/controllers/linux/install/controller.go b/api/controllers/linux/install/controller.go index 580d062b08..2f935655f8 100644 --- a/api/controllers/linux/install/controller.go +++ b/api/controllers/linux/install/controller.go @@ -18,6 +18,7 @@ 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" @@ -64,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 @@ -205,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(), @@ -266,6 +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.WithHelmClient(controller.hcli), ) if err != nil { return nil, fmt.Errorf("create app install controller: %w", err) @@ -286,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_mock.go b/api/controllers/linux/install/controller_mock.go index 4751894864..70354b49ae 100644 --- a/api/controllers/linux/install/controller_mock.go +++ b/api/controllers/linux/install/controller_mock.go @@ -142,8 +142,8 @@ func (m *MockController) RunAppPreflights(ctx context.Context, opts appcontrolle } // InstallApp mocks the InstallApp method -func (m *MockController) InstallApp(ctx context.Context, ignoreAppPreflights bool) error { - args := m.Called(ctx, ignoreAppPreflights) +func (m *MockController) InstallApp(ctx context.Context, opts appcontroller.InstallAppOptions) error { + args := m.Called(ctx, opts) return args.Error(0) } diff --git a/api/controllers/linux/install/controller_test.go b/api/controllers/linux/install/controller_test.go index 8272136aa8..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) @@ -1203,6 +1212,7 @@ func TestSetupInfra(t *testing.T) { appcontroller.WithAppConfigManager(mockAppConfigManager), appcontroller.WithAppPreflightManager(mockAppPreflightManager), appcontroller.WithAppReleaseManager(mockAppReleaseManager), + appcontroller.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -1218,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) @@ -1314,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/docs/docs.go b/api/docs/docs.go index 44bcbc1ce0..a89077c9f8 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"type":"object"},"types.AppConfigValue":{"properties":{"filename":{"type":"string"},"value":{"type":"string"}},"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.AppInstall":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"title":{"type":"string"}},"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, + "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AppComponent":{"properties":{"name":{"description":"Chart name","type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"type":"object"},"types.AppConfigValue":{"properties":{"filename":{"type":"string"},"value":{"type":"string"}},"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.AppInstall":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.AppComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"title":{"type":"string"}},"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"description":"Uses existing Status type","properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, "info": {"contact":{"email":"support@replicated.com","name":"API Support","url":"https://github.com/replicatedhq/embedded-cluster/issues"},"description":"{{escape .Description}}","license":{"name":"Apache 2.0","url":"http://www.apache.org/licenses/LICENSE-2.0.html"},"termsOfService":"http://swagger.io/terms/","title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"OpenAPI","url":"https://swagger.io/resources/open-api/"}, "paths": {"/auth/login":{"post":{"description":"Authenticate a user","operationId":"postAuthLogin","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthRequest"}}},"description":"Auth Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"}},"summary":"Authenticate a user","tags":["auth"]}},"/console/available-network-interfaces":{"get":{"description":"List available network interfaces","operationId":"getConsoleListAvailableNetworkInterfaces","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.GetListAvailableNetworkInterfacesResponse"}}},"description":"OK"}},"summary":"List available network interfaces","tags":["console"]}},"/health":{"get":{"description":"get the health of the API","operationId":"getHealth","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/github_com_replicatedhq_embedded-cluster_api_types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/kubernetes/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postKubernetesInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["kubernetes-install"]}},"/kubernetes/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getKubernetesInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postKubernetesInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getKubernetesInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["kubernetes-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchKubernetesInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["kubernetes-install"]}},"/kubernetes/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postKubernetesInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["kubernetes-install"]}},"/kubernetes/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getKubernetesInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["kubernetes-install"]}},"/kubernetes/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postKubernetesInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["kubernetes-install"]}},"/kubernetes/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getKubernetesInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["kubernetes-install"]}},"/kubernetes/install/installation/config":{"get":{"description":"get the Kubernetes installation config","operationId":"getKubernetesInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the Kubernetes installation config","tags":["kubernetes-install"]}},"/kubernetes/install/installation/configure":{"post":{"description":"configure the Kubernetes installation for install","operationId":"postKubernetesInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Configure the Kubernetes installation for install","tags":["kubernetes-install"]}},"/kubernetes/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getKubernetesInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["kubernetes-install"]}},"/linux/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postLinuxInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["linux-install"]}},"/linux/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getLinuxInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["linux-install"]}},"/linux/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postLinuxInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["linux-install"]}},"/linux/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getLinuxInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["linux-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchLinuxInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["linux-install"]}},"/linux/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postLinuxInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["linux-install"]}},"/linux/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getLinuxInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["linux-install"]}},"/linux/install/host-preflights/run":{"post":{"description":"Run install host preflight checks using installation config and client-provided data","operationId":"postLinuxInstallRunHostPreflights","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PostInstallRunHostPreflightsRequest"}}},"description":"Post Install Run Host Preflights Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Run install host preflight checks","tags":["linux-install"]}},"/linux/install/host-preflights/status":{"get":{"description":"Get the current status and results of host preflight checks for install","operationId":"getLinuxInstallHostPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get host preflight status for install","tags":["linux-install"]}},"/linux/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postLinuxInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInfraSetupRequest"}}},"description":"Infra Setup Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["linux-install"]}},"/linux/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getLinuxInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["linux-install"]}},"/linux/install/installation/config":{"get":{"description":"get the installation config","operationId":"getLinuxInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config","tags":["linux-install"]}},"/linux/install/installation/configure":{"post":{"description":"configure the installation for install","operationId":"postLinuxInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Configure the installation for install","tags":["linux-install"]}},"/linux/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getLinuxInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["linux-install"]}}}, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 3a0eb7729d..15ac1e2849 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1,5 +1,5 @@ { - "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"type":"object"},"types.AppConfigValue":{"properties":{"filename":{"type":"string"},"value":{"type":"string"}},"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.AppInstall":{"properties":{"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"title":{"type":"string"}},"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, + "components": {"schemas":{"github_com_replicatedhq_embedded-cluster_api_types.Health":{"properties":{"status":{"type":"string"}},"type":"object"},"github_com_replicatedhq_kotskinds_multitype.BoolOrString":{"type":"object"},"types.APIError":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/types.APIError"},"type":"array","uniqueItems":false},"field":{"type":"string"},"message":{"type":"string"},"status_code":{"type":"integer"}},"type":"object"},"types.AppComponent":{"properties":{"name":{"description":"Chart name","type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AppConfig":{"properties":{"groups":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigGroup"},"type":"array","uniqueItems":false}},"type":"object"},"types.AppConfigValue":{"properties":{"filename":{"type":"string"},"value":{"type":"string"}},"type":"object"},"types.AppConfigValues":{"additionalProperties":{"$ref":"#/components/schemas/types.AppConfigValue"},"type":"object"},"types.AppConfigValuesResponse":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.AppInstall":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.AppComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.AuthRequest":{"properties":{"password":{"type":"string"}},"type":"object"},"types.AuthResponse":{"properties":{"token":{"type":"string"}},"type":"object"},"types.GetListAvailableNetworkInterfacesResponse":{"properties":{"networkInterfaces":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.Infra":{"properties":{"components":{"items":{"$ref":"#/components/schemas/types.InfraComponent"},"type":"array","uniqueItems":false},"logs":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InfraComponent":{"properties":{"name":{"type":"string"},"status":{"$ref":"#/components/schemas/types.Status"}},"type":"object"},"types.InstallAppPreflightsStatusResponse":{"properties":{"allowIgnoreAppPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.InstallAppRequest":{"properties":{"ignoreAppPreflights":{"type":"boolean"}},"type":"object"},"types.InstallHostPreflightsStatusResponse":{"properties":{"allowIgnoreHostPreflights":{"type":"boolean"},"output":{"$ref":"#/components/schemas/types.PreflightsOutput"},"status":{"$ref":"#/components/schemas/types.Status"},"titles":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"types.KubernetesInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"noProxy":{"type":"string"}},"type":"object"},"types.LinuxInfraSetupRequest":{"properties":{"ignoreHostPreflights":{"type":"boolean"}},"type":"object"},"types.LinuxInstallationConfig":{"properties":{"adminConsolePort":{"type":"integer"},"dataDirectory":{"type":"string"},"globalCidr":{"type":"string"},"httpProxy":{"type":"string"},"httpsProxy":{"type":"string"},"localArtifactMirrorPort":{"type":"integer"},"networkInterface":{"type":"string"},"noProxy":{"type":"string"},"podCidr":{"type":"string"},"serviceCidr":{"type":"string"}},"type":"object"},"types.PatchAppConfigValuesRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"types.PostInstallRunHostPreflightsRequest":{"properties":{"isUi":{"type":"boolean"}},"type":"object"},"types.PreflightsOutput":{"properties":{"fail":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"pass":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false},"warn":{"items":{"$ref":"#/components/schemas/types.PreflightsRecord"},"type":"array","uniqueItems":false}},"type":"object"},"types.PreflightsRecord":{"properties":{"message":{"type":"string"},"title":{"type":"string"}},"type":"object"},"types.State":{"enum":["Pending","Running","Succeeded","Failed"],"example":"Succeeded","type":"string","x-enum-varnames":["StatePending","StateRunning","StateSucceeded","StateFailed"]},"types.Status":{"description":"Uses existing Status type","properties":{"description":{"type":"string"},"lastUpdated":{"type":"string"},"state":{"$ref":"#/components/schemas/types.State"}},"type":"object"},"types.TemplateAppConfigRequest":{"properties":{"values":{"$ref":"#/components/schemas/types.AppConfigValues"}},"type":"object"},"v1beta1.ConfigChildItem":{"properties":{"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"name":{"type":"string"},"recommended":{"type":"boolean"},"title":{"type":"string"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"}},"type":"object"},"v1beta1.ConfigGroup":{"properties":{"description":{"type":"string"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigItem"},"type":"array","uniqueItems":false},"name":{"type":"string"},"title":{"type":"string"},"when":{"type":"string"}},"type":"object"},"v1beta1.ConfigItem":{"properties":{"affix":{"type":"string"},"countByGroup":{"additionalProperties":{"type":"integer"},"type":"object"},"data":{"type":"string"},"default":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"error":{"type":"string"},"filename":{"type":"string"},"help_text":{"type":"string"},"hidden":{"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/v1beta1.ConfigChildItem"},"type":"array","uniqueItems":false},"minimumCount":{"type":"integer"},"multi_value":{"items":{"type":"string"},"type":"array","uniqueItems":false},"multiple":{"type":"boolean"},"name":{"type":"string"},"readonly":{"type":"boolean"},"recommended":{"type":"boolean"},"repeatable":{"type":"boolean"},"required":{"type":"boolean"},"templates":{"items":{"$ref":"#/components/schemas/v1beta1.RepeatTemplate"},"type":"array","uniqueItems":false},"title":{"type":"string"},"type":{"type":"string"},"validation":{"$ref":"#/components/schemas/v1beta1.ConfigItemValidation"},"value":{"$ref":"#/components/schemas/github_com_replicatedhq_kotskinds_multitype.BoolOrString"},"valuesByGroup":{"$ref":"#/components/schemas/v1beta1.ValuesByGroup"},"when":{"type":"string"},"write_once":{"type":"boolean"}},"type":"object"},"v1beta1.ConfigItemValidation":{"properties":{"regex":{"$ref":"#/components/schemas/v1beta1.RegexValidator"}},"type":"object"},"v1beta1.GroupValues":{"additionalProperties":{"type":"string"},"type":"object"},"v1beta1.RegexValidator":{"properties":{"message":{"type":"string"},"pattern":{"type":"string"}},"type":"object"},"v1beta1.RepeatTemplate":{"properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"name":{"type":"string"},"namespace":{"type":"string"},"yamlPath":{"type":"string"}},"type":"object"},"v1beta1.ValuesByGroup":{"additionalProperties":{"$ref":"#/components/schemas/v1beta1.GroupValues"},"type":"object"}},"securitySchemes":{"bearerauth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}}, "info": {"contact":{"email":"support@replicated.com","name":"API Support","url":"https://github.com/replicatedhq/embedded-cluster/issues"},"description":"This is the API for the Embedded Cluster project.","license":{"name":"Apache 2.0","url":"http://www.apache.org/licenses/LICENSE-2.0.html"},"termsOfService":"http://swagger.io/terms/","title":"Embedded Cluster API","version":"0.1"}, "externalDocs": {"description":"OpenAPI","url":"https://swagger.io/resources/open-api/"}, "paths": {"/auth/login":{"post":{"description":"Authenticate a user","operationId":"postAuthLogin","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthRequest"}}},"description":"Auth Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AuthResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Unauthorized"}},"summary":"Authenticate a user","tags":["auth"]}},"/console/available-network-interfaces":{"get":{"description":"List available network interfaces","operationId":"getConsoleListAvailableNetworkInterfaces","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.GetListAvailableNetworkInterfacesResponse"}}},"description":"OK"}},"summary":"List available network interfaces","tags":["console"]}},"/health":{"get":{"description":"get the health of the API","operationId":"getHealth","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/github_com_replicatedhq_embedded-cluster_api_types.Health"}}},"description":"OK"}},"summary":"Get the health of the API","tags":["health"]}},"/kubernetes/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postKubernetesInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["kubernetes-install"]}},"/kubernetes/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getKubernetesInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postKubernetesInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["kubernetes-install"]}},"/kubernetes/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getKubernetesInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["kubernetes-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchKubernetesInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["kubernetes-install"]}},"/kubernetes/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postKubernetesInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["kubernetes-install"]}},"/kubernetes/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getKubernetesInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["kubernetes-install"]}},"/kubernetes/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postKubernetesInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["kubernetes-install"]}},"/kubernetes/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getKubernetesInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["kubernetes-install"]}},"/kubernetes/install/installation/config":{"get":{"description":"get the Kubernetes installation config","operationId":"getKubernetesInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the Kubernetes installation config","tags":["kubernetes-install"]}},"/kubernetes/install/installation/configure":{"post":{"description":"configure the Kubernetes installation for install","operationId":"postKubernetesInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.KubernetesInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Configure the Kubernetes installation for install","tags":["kubernetes-install"]}},"/kubernetes/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getKubernetesInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["kubernetes-install"]}},"/linux/install/app-preflights/run":{"post":{"description":"Run install app preflight checks using current app configuration","operationId":"postLinuxInstallRunAppPreflights","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Run install app preflight checks","tags":["linux-install"]}},"/linux/install/app-preflights/status":{"get":{"description":"Get the current status and results of app preflight checks for install","operationId":"getLinuxInstallAppPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppPreflightsStatusResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app preflight status for install","tags":["linux-install"]}},"/linux/install/app/config/template":{"post":{"description":"Template the app config with provided values and return the templated config","operationId":"postLinuxInstallTemplateAppConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.TemplateAppConfigRequest"}}},"description":"Template App Config Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfig"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Template the app config with provided values","tags":["linux-install"]}},"/linux/install/app/config/values":{"get":{"description":"Get the current app config values","operationId":"getLinuxInstallAppConfigValues","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get the app config values","tags":["linux-install"]},"patch":{"description":"Set the app config values with partial updates","operationId":"patchLinuxInstallAppConfigValues","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PatchAppConfigValuesRequest"}}},"description":"Patch App Config Values Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppConfigValuesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Set the app config values","tags":["linux-install"]}},"/linux/install/app/install":{"post":{"description":"Install the app using current configuration","operationId":"postLinuxInstallApp","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallAppRequest"}}},"description":"Install App Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Install the app","tags":["linux-install"]}},"/linux/install/app/status":{"get":{"description":"Get the current status of app installation","operationId":"getLinuxInstallAppStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.AppInstall"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Get app install status","tags":["linux-install"]}},"/linux/install/host-preflights/run":{"post":{"description":"Run install host preflight checks using installation config and client-provided data","operationId":"postLinuxInstallRunHostPreflights","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.PostInstallRunHostPreflightsRequest"}}},"description":"Post Install Run Host Preflights Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Run install host preflight checks","tags":["linux-install"]}},"/linux/install/host-preflights/status":{"get":{"description":"Get the current status and results of host preflight checks for install","operationId":"getLinuxInstallHostPreflightsStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.InstallHostPreflightsStatusResponse"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get host preflight status for install","tags":["linux-install"]}},"/linux/install/infra/setup":{"post":{"description":"Setup infra components","operationId":"postLinuxInstallSetupInfra","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInfraSetupRequest"}}},"description":"Infra Setup Request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Setup infra components","tags":["linux-install"]}},"/linux/install/infra/status":{"get":{"description":"Get the current status of the infra","operationId":"getLinuxInstallInfraStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Infra"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the status of the infra","tags":["linux-install"]}},"/linux/install/installation/config":{"get":{"description":"get the installation config","operationId":"getLinuxInstallInstallationConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get the installation config","tags":["linux-install"]}},"/linux/install/installation/configure":{"post":{"description":"configure the installation for install","operationId":"postLinuxInstallConfigureInstallation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.LinuxInstallationConfig"}}},"description":"Installation config","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.APIError"}}},"description":"Bad Request"}},"security":[{"bearerauth":[]}],"summary":"Configure the installation for install","tags":["linux-install"]}},"/linux/install/installation/status":{"get":{"description":"Get the current status of the installation configuration for install","operationId":"getLinuxInstallInstallationStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/types.Status"}}},"description":"OK"}},"security":[{"bearerauth":[]}],"summary":"Get installation configuration status for install","tags":["linux-install"]}}}, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 2976e7362e..f3552bf0ec 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -21,6 +21,14 @@ components: status_code: type: integer type: object + types.AppComponent: + properties: + name: + description: Chart name + type: string + status: + $ref: '#/components/schemas/types.Status' + type: object types.AppConfig: properties: groups: @@ -47,6 +55,11 @@ components: type: object types.AppInstall: properties: + components: + items: + $ref: '#/components/schemas/types.AppComponent' + type: array + uniqueItems: false logs: type: string status: @@ -210,6 +223,7 @@ components: - StateSucceeded - StateFailed types.Status: + description: Uses existing Status type properties: description: type: string diff --git a/api/handlers.go b/api/handlers.go index 83a12413ae..464dcb74c0 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -8,6 +8,7 @@ import ( healthhandler "github.com/replicatedhq/embedded-cluster/api/internal/handlers/health" kuberneteshandler "github.com/replicatedhq/embedded-cluster/api/internal/handlers/kubernetes" linuxhandler "github.com/replicatedhq/embedded-cluster/api/internal/handlers/linux" + apitypes "github.com/replicatedhq/embedded-cluster/api/types" ) type handlers struct { @@ -49,28 +50,32 @@ func (a *API) initHandlers() error { } a.handlers.health = healthHandler - // Linux handler - linuxHandler, err := linuxhandler.New( - a.cfg, - linuxhandler.WithLogger(a.logger), - linuxhandler.WithMetricsReporter(a.metricsReporter), - linuxhandler.WithInstallController(a.linuxInstallController), - ) - if err != nil { - return fmt.Errorf("new linux handler: %w", err) - } - a.handlers.linux = linuxHandler + switch a.cfg.InstallTarget { + case apitypes.InstallTargetLinux: + linuxHandler, err := linuxhandler.New( + a.cfg, + 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) + } + a.handlers.linux = linuxHandler - // Kubernetes handler - kubernetesHandler, err := kuberneteshandler.New( - a.cfg, - kuberneteshandler.WithLogger(a.logger), - kuberneteshandler.WithInstallController(a.kubernetesInstallController), - ) - if err != nil { - return fmt.Errorf("new kubernetes handler: %w", err) + case apitypes.InstallTargetKubernetes: + kubernetesHandler, err := kuberneteshandler.New( + 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) + } + a.handlers.kubernetes = kubernetesHandler } - a.handlers.kubernetes = kubernetesHandler return nil } diff --git a/api/integration/app/install/config_test.go b/api/integration/app/install/config_test.go index 3c9a7b9a25..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,10 +1050,11 @@ 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 - return integration.NewAPIWithReleaseData(t, + return integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(controller), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -1068,10 +1071,12 @@ func TestAppInstallSuite(t *testing.T) { kubernetesinstall.WithReleaseData(rd), kubernetesinstall.WithLicense([]byte("spec:\n licenseID: test-license\n")), kubernetesinstall.WithConfigValues(configValues), + kubernetesinstall.WithKubernetesEnvSettings(helmcli.New()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - return integration.NewAPIWithReleaseData(t, + return integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(controller), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/auth/controller_test.go b/api/integration/auth/controller_test.go index 48e2aa5fb1..a4018765d6 100644 --- a/api/integration/auth/controller_test.go +++ b/api/integration/auth/controller_test.go @@ -13,10 +13,11 @@ import ( "github.com/replicatedhq/embedded-cluster/api/controllers/auth" linuxinstall "github.com/replicatedhq/embedded-cluster/api/controllers/linux/install" "github.com/replicatedhq/embedded-cluster/api/integration" - "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/installation" + linuxinstallation "github.com/replicatedhq/embedded-cluster/api/internal/managers/linux/installation" "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" ) @@ -28,15 +29,16 @@ func TestAuthLoginAndTokenValidation(t *testing.T) { // Create an install controller installController, err := linuxinstall.NewInstallController( - linuxinstall.WithInstallationManager(installation.NewInstallationManager( - installation.WithNetUtils(&utils.MockNetUtils{}), + linuxinstall.WithInstallationManager(linuxinstallation.NewInstallationManager( + linuxinstallation.WithNetUtils(&utils.MockNetUtils{}), )), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the auth controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithAuthController(authController), api.WithLinuxInstallController(installController), api.WithLogger(logger.NewDiscardLogger()), @@ -134,7 +136,7 @@ func TestAuthLoginAndTokenValidation(t *testing.T) { func TestAPIClientLogin(t *testing.T) { // Create the API with the auth controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLogger(logger.NewDiscardLogger()), ) diff --git a/api/integration/console/controller_test.go b/api/integration/console/controller_test.go index 6d634bf5ac..ebaea9d80c 100644 --- a/api/integration/console/controller_test.go +++ b/api/integration/console/controller_test.go @@ -29,7 +29,7 @@ func TestConsoleListAvailableNetworkInterfaces(t *testing.T) { require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithConsoleController(consoleController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -73,7 +73,7 @@ func TestConsoleListAvailableNetworkInterfacesUnauthorized(t *testing.T) { require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithConsoleController(consoleController), api.WithAuthController(auth.NewStaticAuthController("VALID_TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -113,7 +113,7 @@ func TestConsoleListAvailableNetworkInterfacesError(t *testing.T) { require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithConsoleController(consoleController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/kubernetes/install/appconfig_test.go b/api/integration/kubernetes/install/appconfig_test.go index 225c6f80e2..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,11 +66,12 @@ func TestInstallController_PatchAppConfigValuesWithAPIClient(t *testing.T) { kubernetesinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -116,11 +118,12 @@ func TestInstallController_PatchAppConfigValuesWithAPIClient(t *testing.T) { kubernetesinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the completed install controller - completedAPIInstance := integration.NewAPIWithReleaseData(t, + completedAPIInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(completedInstallController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -219,11 +222,12 @@ func TestInstallController_GetAppConfigValuesWithAPIClient(t *testing.T) { kubernetesinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/kubernetes/install/appinstall_test.go b/api/integration/kubernetes/install/appinstall_test.go index 138a14fa37..b6d1d69ca1 100644 --- a/api/integration/kubernetes/install/appinstall_test.go +++ b/api/integration/kubernetes/install/appinstall_test.go @@ -15,15 +15,20 @@ import ( kubernetesinstall "github.com/replicatedhq/embedded-cluster/api/controllers/kubernetes/install" "github.com/replicatedhq/embedded-cluster/api/integration" "github.com/replicatedhq/embedded-cluster/api/integration/auth" + appconfig "github.com/replicatedhq/embedded-cluster/api/internal/managers/app/config" appinstallmanager "github.com/replicatedhq/embedded-cluster/api/internal/managers/app/install" + appreleasemanager "github.com/replicatedhq/embedded-cluster/api/internal/managers/app/release" states "github.com/replicatedhq/embedded-cluster/api/internal/states/install" "github.com/replicatedhq/embedded-cluster/api/internal/store" appinstallstore "github.com/replicatedhq/embedded-cluster/api/internal/store/app/install" "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-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" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -32,11 +37,30 @@ import ( // TestGetAppInstallStatus tests the GET /kubernetes/install/app/status endpoint func TestGetAppInstallStatus(t *testing.T) { t.Run("Success", func(t *testing.T) { - // Create app install status + // Create app install status with components appInstallStatus := types.AppInstall{ + Components: []types.AppComponent{ + { + Name: "nginx-chart", + Status: types.Status{ + State: types.StateSucceeded, + Description: "Installation complete", + LastUpdated: time.Now(), + }, + }, + { + Name: "postgres-chart", + Status: types.Status{ + State: types.StateRunning, + Description: "Installing chart", + LastUpdated: time.Now(), + }, + }, + }, Status: types.Status{ State: types.StateRunning, Description: "Installing application", + LastUpdated: time.Now(), }, Logs: "Installation in progress...", } @@ -46,6 +70,7 @@ func TestGetAppInstallStatus(t *testing.T) { appinstallmanager.WithAppInstallStore( appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(appInstallStatus)), ), + appinstallmanager.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -58,6 +83,7 @@ func TestGetAppInstallStatus(t *testing.T) { appinstall.WithStateMachine(kubernetesinstall.NewStateMachine()), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -75,11 +101,12 @@ func TestGetAppInstallStatus(t *testing.T) { }, AppConfig: &kotsv1beta1.Config{}, }), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -106,21 +133,37 @@ func TestGetAppInstallStatus(t *testing.T) { err = json.NewDecoder(rec.Body).Decode(&response) require.NoError(t, err) - // Verify the response + // Verify the response structure includes components assert.Equal(t, appInstallStatus.Status.State, response.Status.State) assert.Equal(t, appInstallStatus.Status.Description, response.Status.Description) assert.Equal(t, appInstallStatus.Logs, response.Logs) + + // Verify components array is present and has expected data + assert.Len(t, response.Components, 2, "Should have 2 components") + + // Verify first component (nginx-chart) + nginxComponent := response.Components[0] + assert.Equal(t, "nginx-chart", nginxComponent.Name) + assert.Equal(t, types.StateSucceeded, nginxComponent.Status.State) + assert.Equal(t, "Installation complete", nginxComponent.Status.Description) + + // Verify second component (postgres-chart) + postgresComponent := response.Components[1] + assert.Equal(t, "postgres-chart", postgresComponent.Name) + assert.Equal(t, types.StateRunning, postgresComponent.Status.State) + assert.Equal(t, "Installing chart", postgresComponent.Status.Description) }) t.Run("Authorization error", func(t *testing.T) { // Create simple Kubernetes install controller installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -147,7 +190,7 @@ func TestGetAppInstallStatus(t *testing.T) { mockController.On("GetAppInstallStatus", mock.Anything).Return(types.AppInstall{}, assert.AnError) // Create the API with mock controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -175,11 +218,63 @@ func TestGetAppInstallStatus(t *testing.T) { // TestPostInstallApp tests the POST /kubernetes/install/app/install endpoint func TestPostInstallApp(t *testing.T) { + // Create test release data + releaseData := &release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + }, + }, + AppConfig: &kotsv1beta1.Config{}, + } + t.Run("Success", func(t *testing.T) { + // Create mock app and kots config values + testAppConfigValues := types.AppConfigValues{ + "service": { + Value: "ClusterIP", + }, + } + testKotsConfigValues := kotsv1beta1.ConfigValues{ + Spec: kotsv1beta1.ConfigValuesSpec{ + Values: map[string]kotsv1beta1.ConfigValue{ + "enable_ingress": { + Value: "1", + }, + }, + }, + } + + // Create app config manager with mock store + mockAppConfigManager := &appconfig.MockAppConfigManager{} + mockAppConfigManager.On("GetConfigValues").Return(testAppConfigValues, nil) + mockAppConfigManager.On("GetKotsadmConfigValues").Return(testKotsConfigValues, nil) + + // Create mock app release manager that returns installable charts + mockAppReleaseManager := &appreleasemanager.MockAppReleaseManager{} + testInstallableCharts := []types.InstallableHelmChart{ + { + Archive: []byte("mock-helm-chart-archive-data"), + Values: map[string]any{ + "service": map[string]any{ + "port": 80, + }, + }, + CR: &kotsv1beta2.HelmChart{ + Spec: kotsv1beta2.HelmChartSpec{ + ReleaseName: "test-app", + Namespace: "default", + }, + }, + }, + } + mockAppReleaseManager.On("ExtractInstallableHelmCharts", mock.Anything, testAppConfigValues, mock.AnythingOfType("*v1beta1.ProxySpec"), mock.AnythingOfType("*types.RegistrySettings")).Return(testInstallableCharts, nil) // Create mock app install manager that succeeds mockAppInstallManager := &appinstallmanager.MockAppInstallManager{} - mockAppInstallManager.On("Install", mock.Anything, mock.Anything).Return(nil) + mockAppInstallManager.On("Install", mock.Anything, testInstallableCharts, testKotsConfigValues).Return(nil) mockAppInstallManager.On("GetStatus").Return(types.AppInstall{ Status: types.Status{ State: types.StateRunning, @@ -187,22 +282,20 @@ func TestPostInstallApp(t *testing.T) { }, }, nil) - // Create mock store - mockStore := &store.MockStore{} - mockStore.AppConfigMockStore.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{}, nil) - mockStore.AppConfigMockStore.On("GetConfigValues").Return(types.AppConfigValues{}, nil) - // Create state machine starting from AppPreflightsSucceeded (valid state for app install) stateMachine := kubernetesinstall.NewStateMachine( kubernetesinstall.WithCurrentState(states.StateAppPreflightsSucceeded), ) - // Create real app install controller with mock manager + // Create real app install controller with mock managers appInstallController, err := appinstall.NewInstallController( appinstall.WithAppInstallManager(mockAppInstallManager), + appinstall.WithAppReleaseManager(mockAppReleaseManager), + appinstall.WithAppConfigManager(mockAppConfigManager), appinstall.WithStateMachine(stateMachine), - appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithStore(&store.MockStore{}), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -210,25 +303,27 @@ func TestPostInstallApp(t *testing.T) { installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), - kubernetesinstall.WithReleaseData(&release.ReleaseData{ - EmbeddedClusterConfig: &ecv1beta1.Config{}, - ChannelRelease: &release.ChannelRelease{ - DefaultDomains: release.Domains{ - ReplicatedAppDomain: "replicated.example.com", - ProxyRegistryDomain: "some-proxy.example.com", - }, - }, - AppConfig: &kotsv1beta1.Config{}, - }), + kubernetesinstall.WithReleaseData(releaseData), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetKubernetes, + Password: "password", + ReleaseData: releaseData, + KubernetesConfig: types.KubernetesConfig{ + Installation: kubernetesinstallation.New(nil), + }, + } + apiInstance, err := api.New(cfg, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) // Create a new router and register API routes router := mux.NewRouter() @@ -251,6 +346,8 @@ func TestPostInstallApp(t *testing.T) { }, 10*time.Second, 100*time.Millisecond, "state should transition to Succeeded") // Verify that the app install manager was called (no metrics reporting verification) + mockAppConfigManager.AssertExpectations(t) + mockAppReleaseManager.AssertExpectations(t) mockAppInstallManager.AssertExpectations(t) }) @@ -265,7 +362,8 @@ func TestPostInstallApp(t *testing.T) { appInstallController, err := appinstall.NewInstallController( appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -273,16 +371,27 @@ func TestPostInstallApp(t *testing.T) { installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), - kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithReleaseData(releaseData), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetKubernetes, + Password: "password", + ReleaseData: releaseData, + KubernetesConfig: types.KubernetesConfig{ + Installation: kubernetesinstallation.New(nil), + }, + } + apiInstance, err := api.New(cfg, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) // Create a new router and register API routes router := mux.NewRouter() @@ -303,7 +412,7 @@ func TestPostInstallApp(t *testing.T) { t.Run("App installation failure", func(t *testing.T) { // Create mock app install manager that fails mockAppInstallManager := &appinstallmanager.MockAppInstallManager{} - mockAppInstallManager.On("Install", mock.Anything, mock.Anything).Return(errors.New("installation failed")) + mockAppInstallManager.On("Install", mock.Anything, mock.Anything, mock.Anything).Return(errors.New("installation failed")) mockAppInstallManager.On("GetStatus").Return(types.AppInstall{ Status: types.Status{ State: types.StateFailed, @@ -326,7 +435,8 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithAppInstallManager(mockAppInstallManager), appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -334,16 +444,27 @@ func TestPostInstallApp(t *testing.T) { installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), - kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithReleaseData(releaseData), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetKubernetes, + Password: "password", + ReleaseData: releaseData, + KubernetesConfig: types.KubernetesConfig{ + Installation: kubernetesinstallation.New(nil), + }, + } + apiInstance, err := api.New(cfg, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) // Create a new router and register API routes router := mux.NewRouter() @@ -372,12 +493,13 @@ func TestPostInstallApp(t *testing.T) { t.Run("Authorization error", func(t *testing.T) { // Create simple Kubernetes install controller installController, err := kubernetesinstall.NewInstallController( - kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithReleaseData(releaseData), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -407,7 +529,7 @@ func TestPostInstallApp(t *testing.T) { // Create mock app install manager that succeeds mockAppInstallManager := &appinstallmanager.MockAppInstallManager{} - mockAppInstallManager.On("Install", mock.Anything, mock.Anything).Return(nil) + mockAppInstallManager.On("Install", mock.Anything, mock.Anything, mock.Anything).Return(nil) mockAppInstallManager.On("GetStatus").Return(types.AppInstall{ Status: types.Status{ State: types.StateRunning, @@ -425,7 +547,8 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithAppInstallManager(mockAppInstallManager), appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -433,16 +556,27 @@ func TestPostInstallApp(t *testing.T) { installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), - kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithReleaseData(releaseData), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetKubernetes, + Password: "password", + ReleaseData: releaseData, + KubernetesConfig: types.KubernetesConfig{ + Installation: kubernetesinstallation.New(nil), + }, + } + apiInstance, err := api.New(cfg, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) router := mux.NewRouter() apiInstance.RegisterRoutes(router) @@ -488,7 +622,8 @@ func TestPostInstallApp(t *testing.T) { appInstallController, err := appinstall.NewInstallController( appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -496,16 +631,27 @@ func TestPostInstallApp(t *testing.T) { installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), - kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithReleaseData(releaseData), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetKubernetes, + Password: "password", + ReleaseData: releaseData, + KubernetesConfig: types.KubernetesConfig{ + Installation: kubernetesinstallation.New(nil), + }, + } + apiInstance, err := api.New(cfg, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) router := mux.NewRouter() apiInstance.RegisterRoutes(router) diff --git a/api/integration/kubernetes/install/apppreflight_test.go b/api/integration/kubernetes/install/apppreflight_test.go index f28350b602..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,6 +73,7 @@ func TestGetAppPreflightsStatus(t *testing.T) { appinstall.WithStateMachine(kubernetesinstall.NewStateMachine()), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -80,11 +81,12 @@ func TestGetAppPreflightsStatus(t *testing.T) { installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithAppInstallController(appInstallController), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -139,7 +141,7 @@ func TestGetAppPreflightsStatus(t *testing.T) { mockController.On("GetAppPreflightTitles", mock.Anything).Return([]string{}, assert.AnError) // Create the API with the mock controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -226,6 +228,7 @@ func TestPostRunAppPreflights(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -234,21 +237,23 @@ func TestPostRunAppPreflights(t *testing.T) { kubernetesinstall.WithStateMachine(stateMachine), kubernetesinstall.WithAppInstallController(appInstallController), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with kubernetes config in the API config apiInstance, err := api.New(types.APIConfig{ - Password: "password", + 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) @@ -285,21 +290,23 @@ func TestPostRunAppPreflights(t *testing.T) { kubernetesinstall.WithCurrentState(states.StateNew), // Wrong state )), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with kubernetes config apiInstance, err := api.New(types.APIConfig{ - Password: "password", + 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) @@ -330,21 +337,23 @@ func TestPostRunAppPreflights(t *testing.T) { // Create a basic install controller installController, err := kubernetesinstall.NewInstallController( kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with kubernetes config apiInstance, err := api.New(types.APIConfig{ - Password: "password", + 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 3837f5295f..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{ @@ -137,7 +138,7 @@ func TestKubernetesPostSetupInfra(t *testing.T) { require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -227,9 +228,25 @@ 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{ + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + }, + }, + AppConfig: &appConfig, + }), + ) + require.NoError(t, err) + // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithAuthController(auth.NewStaticAuthController("TOKEN")), + api.WithKubernetesInstallController(installController), api.WithLogger(logger.NewDiscardLogger()), ) @@ -251,7 +268,7 @@ func TestKubernetesPostSetupInfra(t *testing.T) { // Parse the response body var apiError types.APIError - err := json.NewDecoder(rec.Body).Decode(&apiError) + err = json.NewDecoder(rec.Body).Decode(&apiError) require.NoError(t, err) assert.Equal(t, http.StatusUnauthorized, apiError.StatusCode) }) @@ -302,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{ @@ -316,7 +334,7 @@ func TestKubernetesPostSetupInfra(t *testing.T) { require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/kubernetes/install/installation_test.go b/api/integration/kubernetes/install/installation_test.go index e5265bcffe..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,11 +138,12 @@ func TestKubernetesConfigureInstallation(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(states.StateApplicationConfigured))), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -228,11 +230,12 @@ func TestKubernetesConfigureInstallationValidation(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(states.StateApplicationConfigured))), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -286,10 +289,11 @@ func TestKubernetesConfigureInstallationBadRequest(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithStateMachine(kubernetesinstall.NewStateMachine(kubernetesinstall.WithCurrentState(states.StateApplicationConfigured))), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -321,7 +325,7 @@ func TestKubernetesConfigureInstallationControllerError(t *testing.T) { mockController.On("ConfigureInstallation", mock.Anything, mock.Anything).Return(assert.AnError) // Create the API with the mock controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -370,6 +374,7 @@ func TestKubernetesGetInstallationConfig(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithInstallationManager(installationManager), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -384,7 +389,7 @@ func TestKubernetesGetInstallationConfig(t *testing.T) { require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -432,11 +437,12 @@ func TestKubernetesGetInstallationConfig(t *testing.T) { kubernetesinstall.WithInstallation(ki), kubernetesinstall.WithInstallationManager(emptyInstallationManager), kubernetesinstall.WithReleaseData(integration.DefaultReleaseData()), + kubernetesinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - emptyAPI := integration.NewAPIWithReleaseData(t, + emptyAPI := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(emptyInstallController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -497,7 +503,7 @@ func TestKubernetesGetInstallationConfig(t *testing.T) { mockController.On("GetInstallationConfig", mock.Anything).Return(types.KubernetesInstallationConfig{}, assert.AnError) // Create the API with the mock controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetKubernetesAPIWithReleaseData(t, api.WithKubernetesInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/linux/install/appconfig_test.go b/api/integration/linux/install/appconfig_test.go index a24fd139f0..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,11 +66,12 @@ func TestInstallController_PatchAppConfigValuesWithAPIClient(t *testing.T) { linuxinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -116,11 +118,12 @@ func TestInstallController_PatchAppConfigValuesWithAPIClient(t *testing.T) { linuxinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the completed install controller - completedAPIInstance := integration.NewAPIWithReleaseData(t, + completedAPIInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(completedInstallController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -219,11 +222,12 @@ func TestInstallController_GetAppConfigValuesWithAPIClient(t *testing.T) { linuxinstall.WithReleaseData(&release.ReleaseData{ AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/linux/install/appinstall_test.go b/api/integration/linux/install/appinstall_test.go index 49a44eae1c..22b02e6cd0 100644 --- a/api/integration/linux/install/appinstall_test.go +++ b/api/integration/linux/install/appinstall_test.go @@ -15,30 +15,83 @@ import ( linuxinstall "github.com/replicatedhq/embedded-cluster/api/controllers/linux/install" "github.com/replicatedhq/embedded-cluster/api/integration" "github.com/replicatedhq/embedded-cluster/api/integration/auth" + appconfig "github.com/replicatedhq/embedded-cluster/api/internal/managers/app/config" appinstallmanager "github.com/replicatedhq/embedded-cluster/api/internal/managers/app/install" + appreleasemanager "github.com/replicatedhq/embedded-cluster/api/internal/managers/app/release" states "github.com/replicatedhq/embedded-cluster/api/internal/states/install" "github.com/replicatedhq/embedded-cluster/api/internal/store" appinstallstore "github.com/replicatedhq/embedded-cluster/api/internal/store/app/install" "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" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +// mockLicense creates a mock license for testing +func mockLicense() []byte { + return []byte(` +apiVersion: kots.io/v1beta1 +kind: License +spec: + licenseID: "test-license-id-12345" + appSlug: "test-app" + customerName: "Test Customer" +`) +} + // TestGetAppInstallStatus tests the GET /linux/install/app/status endpoint func TestGetAppInstallStatus(t *testing.T) { + // Create mock helm chart archive + mockChartArchive := []byte("mock-helm-chart-archive-data") + + // Create test release data with helm chart archives + releaseData := &release.ReleaseData{ + HelmChartArchives: [][]byte{mockChartArchive}, + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + AppSlug: "test-app", + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + ReplicatedRegistryDomain: "registry.example.com", + }, + }, + AppConfig: &kotsv1beta1.Config{}, + } + t.Run("Success", func(t *testing.T) { - // Create app install status + // Create app install status with components appInstallStatus := types.AppInstall{ + Components: []types.AppComponent{ + { + Name: "nginx-chart", + Status: types.Status{ + State: types.StateSucceeded, + Description: "Installation complete", + LastUpdated: time.Now(), + }, + }, + { + Name: "postgres-chart", + Status: types.Status{ + State: types.StateRunning, + Description: "Installing chart", + LastUpdated: time.Now(), + }, + }, + }, Status: types.Status{ State: types.StateRunning, Description: "Installing application", + LastUpdated: time.Now(), }, Logs: "Installation in progress...", } @@ -48,6 +101,7 @@ func TestGetAppInstallStatus(t *testing.T) { appinstallmanager.WithAppInstallStore( appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(appInstallStatus)), ), + appinstallmanager.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -59,7 +113,8 @@ func TestGetAppInstallStatus(t *testing.T) { appinstall.WithAppInstallManager(appInstallManager), appinstall.WithStateMachine(linuxinstall.NewStateMachine()), appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -67,25 +122,19 @@ func TestGetAppInstallStatus(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithStateMachine(linuxinstall.NewStateMachine()), linuxinstall.WithAppInstallController(appInstallController), - linuxinstall.WithReleaseData(&release.ReleaseData{ - EmbeddedClusterConfig: &ecv1beta1.Config{}, - ChannelRelease: &release.ChannelRelease{ - DefaultDomains: release.Domains{ - ReplicatedAppDomain: "replicated.example.com", - ProxyRegistryDomain: "some-proxy.example.com", - }, - }, - AppConfig: &kotsv1beta1.Config{}, - }), + linuxinstall.WithReleaseData(releaseData), linuxinstall.WithRuntimeConfig(runtimeconfig.New(nil)), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(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 @@ -109,24 +158,42 @@ func TestGetAppInstallStatus(t *testing.T) { err = json.NewDecoder(rec.Body).Decode(&response) require.NoError(t, err) - // Verify the response + // Verify the response structure includes components assert.Equal(t, appInstallStatus.Status.State, response.Status.State) assert.Equal(t, appInstallStatus.Status.Description, response.Status.Description) assert.Equal(t, appInstallStatus.Logs, response.Logs) + + // Verify components array is present and has expected data + assert.Len(t, response.Components, 2, "Should have 2 components") + + // Verify first component (nginx-chart) + nginxComponent := response.Components[0] + assert.Equal(t, "nginx-chart", nginxComponent.Name) + assert.Equal(t, types.StateSucceeded, nginxComponent.Status.State) + assert.Equal(t, "Installation complete", nginxComponent.Status.Description) + + // Verify second component (postgres-chart) + postgresComponent := response.Components[1] + assert.Equal(t, "postgres-chart", postgresComponent.Name) + assert.Equal(t, types.StateRunning, postgresComponent.Status.State) + assert.Equal(t, "Installing chart", postgresComponent.Status.Description) }) t.Run("Authorization error", func(t *testing.T) { // Create simple Linux install controller installController, err := linuxinstall.NewInstallController( - linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithReleaseData(releaseData), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(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 @@ -150,10 +217,11 @@ func TestGetAppInstallStatus(t *testing.T) { mockController.On("GetAppInstallStatus", mock.Anything).Return(types.AppInstall{}, assert.AnError) // Create the API with mock controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(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 @@ -178,6 +246,20 @@ func TestGetAppInstallStatus(t *testing.T) { // TestPostInstallApp tests the POST /linux/install/app/install endpoint func TestPostInstallApp(t *testing.T) { + // Create test release data + releaseData := &release.ReleaseData{ + EmbeddedClusterConfig: &ecv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + AppSlug: "test-app", + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + ReplicatedRegistryDomain: "registry.example.com", + }, + }, + AppConfig: &kotsv1beta1.Config{}, + } + t.Run("Success", func(t *testing.T) { // Create a real runtime config with temp directory rc := runtimeconfig.New(nil) @@ -187,9 +269,50 @@ func TestPostInstallApp(t *testing.T) { mockReporter := &metrics.MockReporter{} mockReporter.On("ReportInstallationSucceeded", mock.Anything) + // Create mock app and kots config values + testAppConfigValues := types.AppConfigValues{ + "service": { + Value: "ClusterIP", + }, + } + testKotsConfigValues := kotsv1beta1.ConfigValues{ + Spec: kotsv1beta1.ConfigValuesSpec{ + Values: map[string]kotsv1beta1.ConfigValue{ + "enable_ingress": { + Value: "1", + }, + }, + }, + } + + // Create app config manager with mock store + mockAppConfigManager := &appconfig.MockAppConfigManager{} + mockAppConfigManager.On("GetConfigValues").Return(testAppConfigValues, nil) + mockAppConfigManager.On("GetKotsadmConfigValues").Return(testKotsConfigValues, nil) + + // Create mock app release manager that returns installable charts + mockAppReleaseManager := &appreleasemanager.MockAppReleaseManager{} + testInstallableCharts := []types.InstallableHelmChart{ + { + Archive: []byte("mock-helm-chart-archive-data"), + Values: map[string]any{ + "service": map[string]any{ + "port": 80, + }, + }, + CR: &kotsv1beta2.HelmChart{ + Spec: kotsv1beta2.HelmChartSpec{ + ReleaseName: "test-app", + Namespace: "default", + }, + }, + }, + } + mockAppReleaseManager.On("ExtractInstallableHelmCharts", mock.Anything, testAppConfigValues, mock.AnythingOfType("*v1beta1.ProxySpec"), mock.AnythingOfType("*types.RegistrySettings")).Return(testInstallableCharts, nil) + // Create mock app install manager that succeeds mockAppInstallManager := &appinstallmanager.MockAppInstallManager{} - mockAppInstallManager.On("Install", mock.Anything, mock.Anything).Return(nil) + mockAppInstallManager.On("Install", mock.Anything, testInstallableCharts, testKotsConfigValues).Return(nil) mockAppInstallManager.On("GetStatus").Return(types.AppInstall{ Status: types.Status{ State: types.StateRunning, @@ -197,22 +320,20 @@ func TestPostInstallApp(t *testing.T) { }, }, nil) - // Create mock store - mockStore := &store.MockStore{} - mockStore.AppConfigMockStore.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{}, nil) - mockStore.AppConfigMockStore.On("GetConfigValues").Return(types.AppConfigValues{}, nil) - // Create state machine starting from AppPreflightsSucceeded (valid state for app install) stateMachine := linuxinstall.NewStateMachine( linuxinstall.WithCurrentState(states.StateAppPreflightsSucceeded), ) - // Create real app install controller with mock manager + // Create real app install controller with mock managers appInstallController, err := appinstall.NewInstallController( appinstall.WithAppInstallManager(mockAppInstallManager), + appinstall.WithAppReleaseManager(mockAppReleaseManager), + appinstall.WithAppConfigManager(mockAppConfigManager), appinstall.WithStateMachine(stateMachine), - appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithStore(&store.MockStore{}), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -221,26 +342,29 @@ func TestPostInstallApp(t *testing.T) { linuxinstall.WithStateMachine(stateMachine), linuxinstall.WithAppInstallController(appInstallController), linuxinstall.WithMetricsReporter(mockReporter), - linuxinstall.WithReleaseData(&release.ReleaseData{ - EmbeddedClusterConfig: &ecv1beta1.Config{}, - ChannelRelease: &release.ChannelRelease{ - DefaultDomains: release.Domains{ - ReplicatedAppDomain: "replicated.example.com", - ProxyRegistryDomain: "some-proxy.example.com", - }, - }, - AppConfig: &kotsv1beta1.Config{}, - }), + linuxinstall.WithReleaseData(releaseData), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetLinux, + Password: "password", + ReleaseData: releaseData, + LinuxConfig: types.LinuxConfig{ + RuntimeConfig: rc, + }, + } + apiInstance, err := api.New(cfg, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) // Create a new router and register API routes router := mux.NewRouter() @@ -264,10 +388,16 @@ func TestPostInstallApp(t *testing.T) { // Verify that ReportInstallationSucceeded was called mockReporter.AssertExpectations(t) + mockAppConfigManager.AssertExpectations(t) + mockAppReleaseManager.AssertExpectations(t) mockAppInstallManager.AssertExpectations(t) }) t.Run("Invalid state transition", func(t *testing.T) { + // Create a real runtime config with temp directory + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + // Create state machine starting from invalid state (infrastructure installing) stateMachine := linuxinstall.NewStateMachine( linuxinstall.WithCurrentState(states.StateInfrastructureInstalling), @@ -278,7 +408,8 @@ func TestPostInstallApp(t *testing.T) { appInstallController, err := appinstall.NewInstallController( appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -286,16 +417,28 @@ func TestPostInstallApp(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithStateMachine(stateMachine), linuxinstall.WithAppInstallController(appInstallController), - linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithReleaseData(releaseData), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetLinux, + Password: "password", + ReleaseData: releaseData, + LinuxConfig: types.LinuxConfig{ + RuntimeConfig: rc, + }, + } + apiInstance, err := api.New(cfg, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) // Create a new router and register API routes router := mux.NewRouter() @@ -326,7 +469,7 @@ func TestPostInstallApp(t *testing.T) { // Create mock app install manager that fails mockAppInstallManager := &appinstallmanager.MockAppInstallManager{} - mockAppInstallManager.On("Install", mock.Anything, mock.Anything).Return(errors.New("installation failed")) + mockAppInstallManager.On("Install", mock.Anything, mock.Anything, mock.Anything).Return(errors.New("installation failed")) mockAppInstallManager.On("GetStatus").Return(types.AppInstall{ Status: types.Status{ State: types.StateFailed, @@ -353,7 +496,8 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithAppInstallManager(mockAppInstallManager), appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -363,17 +507,29 @@ func TestPostInstallApp(t *testing.T) { linuxinstall.WithAppInstallController(appInstallController), linuxinstall.WithMetricsReporter(mockReporter), linuxinstall.WithStore(mockStore), - linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithReleaseData(releaseData), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetLinux, + Password: "password", + ReleaseData: releaseData, + LinuxConfig: types.LinuxConfig{ + RuntimeConfig: rc, + }, + } + apiInstance, err := api.New(cfg, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) // Create a new router and register API routes router := mux.NewRouter() @@ -402,18 +558,34 @@ func TestPostInstallApp(t *testing.T) { }) t.Run("Authorization error", func(t *testing.T) { + // Create a real runtime config with temp directory + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + // Create simple Linux install controller installController, err := linuxinstall.NewInstallController( - linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithReleaseData(releaseData), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetLinux, + Password: "password", + ReleaseData: releaseData, + LinuxConfig: types.LinuxConfig{ + RuntimeConfig: rc, + }, + } + apiInstance, err := api.New(cfg, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) // Create a new router and register API routes router := mux.NewRouter() @@ -432,6 +604,10 @@ func TestPostInstallApp(t *testing.T) { // Test app preflight bypass - should succeed when ignoreAppPreflights is true t.Run("App preflight bypass with failed preflights - ignored", func(t *testing.T) { + // Create a real runtime config with temp directory + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + // Create mock store mockStore := &store.MockStore{} mockStore.AppConfigMockStore.On("GetKotsadmConfigValues").Return(kotsv1beta1.ConfigValues{}, nil) @@ -439,7 +615,7 @@ func TestPostInstallApp(t *testing.T) { // Create mock app install manager that succeeds mockAppInstallManager := &appinstallmanager.MockAppInstallManager{} - mockAppInstallManager.On("Install", mock.Anything, mock.Anything).Return(nil) + mockAppInstallManager.On("Install", mock.Anything, mock.Anything, mock.Anything).Return(nil) mockAppInstallManager.On("GetStatus").Return(types.AppInstall{ Status: types.Status{ State: types.StateRunning, @@ -457,7 +633,8 @@ func TestPostInstallApp(t *testing.T) { appinstall.WithAppInstallManager(mockAppInstallManager), appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -465,16 +642,28 @@ func TestPostInstallApp(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithStateMachine(stateMachine), linuxinstall.WithAppInstallController(appInstallController), - linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithReleaseData(releaseData), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetLinux, + Password: "password", + ReleaseData: releaseData, + LinuxConfig: types.LinuxConfig{ + RuntimeConfig: rc, + }, + } + apiInstance, err := api.New(cfg, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) router := mux.NewRouter() apiInstance.RegisterRoutes(router) @@ -508,6 +697,10 @@ func TestPostInstallApp(t *testing.T) { // Test app preflight bypass denied - should fail when ignoreAppPreflights is false and preflights failed t.Run("App preflight bypass denied with failed preflights", func(t *testing.T) { + // Create a real runtime config with temp directory + rc := runtimeconfig.New(nil) + rc.SetDataDir(t.TempDir()) + // Create mock store mockStore := &store.MockStore{} @@ -520,7 +713,8 @@ func TestPostInstallApp(t *testing.T) { appInstallController, err := appinstall.NewInstallController( appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), - appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithReleaseData(releaseData), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -528,16 +722,28 @@ func TestPostInstallApp(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithStateMachine(stateMachine), linuxinstall.WithAppInstallController(appInstallController), - linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithReleaseData(releaseData), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetLinux, + Password: "password", + ReleaseData: releaseData, + LinuxConfig: types.LinuxConfig{ + RuntimeConfig: rc, + }, + } + apiInstance, err := api.New(cfg, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) + require.NoError(t, err) router := mux.NewRouter() apiInstance.RegisterRoutes(router) diff --git a/api/integration/linux/install/apppreflight_test.go b/api/integration/linux/install/apppreflight_test.go index 4f92ff9fbf..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,6 +75,7 @@ func TestGetAppPreflightsStatus(t *testing.T) { appinstall.WithStateMachine(linuxinstall.NewStateMachine()), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -81,14 +83,16 @@ func TestGetAppPreflightsStatus(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithAppInstallController(appInstallController), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(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 @@ -140,10 +144,11 @@ func TestGetAppPreflightsStatus(t *testing.T) { mockController.On("GetAppPreflightTitles", mock.Anything).Return([]string{}, assert.AnError) // Create the API with the mock controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), + api.WithHelmClient(&helm.MockClient{}), ) router := mux.NewRouter() @@ -226,6 +231,7 @@ func TestPostRunAppPreflights(t *testing.T) { appinstall.WithStateMachine(stateMachine), appinstall.WithStore(mockStore), appinstall.WithReleaseData(integration.DefaultReleaseData()), + appinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -236,20 +242,25 @@ func TestPostRunAppPreflights(t *testing.T) { linuxinstall.WithReleaseData(&release.ReleaseData{ EmbeddedClusterConfig: &ecv1beta1.Config{}, ChannelRelease: &release.ChannelRelease{ + AppSlug: "test-app", DefaultDomains: release.Domains{ - ReplicatedAppDomain: "replicated.example.com", - ProxyRegistryDomain: "some-proxy.example.com", + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + ReplicatedRegistryDomain: "registry.example.com", }, }, AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with runtime config in the API config apiInstance, err := api.New(types.APIConfig{ - Password: "password", + InstallTarget: types.InstallTargetLinux, + Password: "password", LinuxConfig: types.LinuxConfig{ RuntimeConfig: rc, }, @@ -258,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) @@ -292,14 +304,27 @@ func TestPostRunAppPreflights(t *testing.T) { linuxinstall.WithStateMachine(linuxinstall.NewStateMachine( linuxinstall.WithCurrentState(states.StateNew), // Wrong state )), - linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithReleaseData(&release.ReleaseData{ + AppConfig: &kotsv1beta1.Config{}, + ChannelRelease: &release.ChannelRelease{ + AppSlug: "test-app", + DefaultDomains: release.Domains{ + ReplicatedAppDomain: "replicated.example.com", + ProxyRegistryDomain: "some-proxy.example.com", + ReplicatedRegistryDomain: "registry.example.com", + }, + }, + }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithLicense(mockLicense()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with runtime config apiInstance, err := api.New(types.APIConfig{ - Password: "password", + InstallTarget: types.InstallTargetLinux, + Password: "password", LinuxConfig: types.LinuxConfig{ RuntimeConfig: rc, }, @@ -308,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) @@ -338,12 +364,14 @@ 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) // Create the API with runtime config apiInstance, err := api.New(types.APIConfig{ - Password: "password", + InstallTarget: types.InstallTargetLinux, + Password: "password", LinuxConfig: types.LinuxConfig{ RuntimeConfig: rc, }, @@ -352,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 a19caf1a0e..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,11 +72,12 @@ func TestGetHostPreflightsStatus(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithHostPreflightManager(manager), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -140,7 +142,7 @@ func TestGetHostPreflightsStatus(t *testing.T) { mockController.On("GetHostPreflightStatus", mock.Anything).Return(types.Status{}, assert.AnError) // Create the API with the mock controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -218,13 +220,15 @@ func TestGetHostPreflightsStatusWithIgnoreFlag(t *testing.T) { installController, err := linuxinstall.NewInstallController( linuxinstall.WithHostPreflightManager(manager), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with allow ignore host preflights flag apiInstance, err := api.New( types.APIConfig{ - Password: "password", + InstallTarget: types.InstallTargetLinux, + Password: "password", LinuxConfig: types.LinuxConfig{ AllowIgnoreHostPreflights: tt.allowIgnoreHostPreflights, }, @@ -233,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) @@ -305,6 +310,7 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -339,7 +345,7 @@ func TestPostRunHostPreflights(t *testing.T) { ) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -403,11 +409,12 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -460,11 +467,12 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -518,11 +526,12 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -589,11 +598,12 @@ func TestPostRunHostPreflights(t *testing.T) { AppConfig: &kotsv1beta1.Config{}, }), linuxinstall.WithRuntimeConfig(rc), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/linux/install/infra_test.go b/api/integration/linux/install/infra_test.go index 030909c009..f96c371e32 100644 --- a/api/integration/linux/install/infra_test.go +++ b/api/integration/linux/install/infra_test.go @@ -178,11 +178,12 @@ func TestLinuxPostSetupInfra(t *testing.T) { }, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -294,7 +295,7 @@ func TestLinuxPostSetupInfra(t *testing.T) { // Test authorization t.Run("Authorization error", func(t *testing.T) { // Create the API - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), ) @@ -353,11 +354,12 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -411,11 +413,12 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -476,11 +479,12 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -540,11 +544,12 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -604,11 +609,12 @@ func TestLinuxPostSetupInfra(t *testing.T) { ChannelRelease: &release.ChannelRelease{}, AppConfig: &appConfig, }), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -689,11 +695,12 @@ func TestLinuxPostSetupInfra(t *testing.T) { }), linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithStateMachine(linuxinstall.NewStateMachine(linuxinstall.WithCurrentState(states.StateHostPreflightsSucceeded))), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/linux/install/installation_test.go b/api/integration/linux/install/installation_test.go index f5c35db25f..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,11 +202,12 @@ 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) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -299,11 +301,12 @@ 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) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -359,10 +362,11 @@ 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) - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -394,7 +398,7 @@ func TestLinuxConfigureInstallationControllerError(t *testing.T) { mockController.On("ConfigureInstallation", mock.Anything, mock.Anything).Return(assert.AnError) // Create the API with the mock controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -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) @@ -458,7 +463,7 @@ func TestLinuxGetInstallationConfig(t *testing.T) { require.NoError(t, err) // Create the API with the install controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(installController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -515,11 +520,12 @@ func TestLinuxGetInstallationConfig(t *testing.T) { linuxinstall.WithRuntimeConfig(rc), linuxinstall.WithInstallationManager(emptyInstallationManager), linuxinstall.WithReleaseData(integration.DefaultReleaseData()), + linuxinstall.WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) // Create the API with the install controller - emptyAPI := integration.NewAPIWithReleaseData(t, + emptyAPI := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(emptyInstallController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -582,7 +588,7 @@ func TestLinuxGetInstallationConfig(t *testing.T) { mockController.On("GetInstallationConfig", mock.Anything).Return(types.LinuxInstallationConfig{}, assert.AnError) // Create the API with the mock controller - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithLinuxInstallController(mockController), api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLogger(logger.NewDiscardLogger()), @@ -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) @@ -659,7 +666,7 @@ func TestLinuxInstallationConfigWithAPIClient(t *testing.T) { require.NoError(t, err) // Create the API with controllers - apiInstance := integration.NewAPIWithReleaseData(t, + apiInstance := integration.NewTargetLinuxAPIWithReleaseData(t, api.WithAuthController(auth.NewStaticAuthController("TOKEN")), api.WithLinuxInstallController(installController), api.WithLogger(logger.NewDiscardLogger()), diff --git a/api/integration/util.go b/api/integration/util.go index 26de476086..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" @@ -57,12 +58,36 @@ func NewTestInterceptorFuncs() interceptor.Funcs { } } -func NewAPIWithReleaseData(t *testing.T, opts ...api.Option) *api.API { +func NewTargetLinuxAPIWithReleaseData(t *testing.T, opts ...api.Option) *api.API { cfg := types.APIConfig{ - Password: "password", - ReleaseData: DefaultReleaseData(), + InstallTarget: types.InstallTargetLinux, + 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 +} + +func NewTargetKubernetesAPIWithReleaseData(t *testing.T, opts ...api.Option) *api.API { + cfg := types.APIConfig{ + InstallTarget: types.InstallTargetKubernetes, + Password: "password", + ReleaseData: DefaultReleaseData(), + } + + // 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/clients/kube.go b/api/internal/clients/kube.go index f591fe291a..b690cb3657 100644 --- a/api/internal/clients/kube.go +++ b/api/internal/clients/kube.go @@ -11,6 +11,7 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/discovery" coreclientset "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/metadata" "k8s.io/client-go/rest" @@ -44,48 +45,47 @@ func getScheme() *runtime.Scheme { // NewKubeClient returns a new kubernetes client. func NewKubeClient(opts KubeClientOptions) (client.Client, error) { - var restConfig *rest.Config - if opts.RESTClientGetter == nil && opts.KubeConfigPath == "" { - return nil, fmt.Errorf("a valid kube config is required to create a kube client") + restConfig, err := getRESTConfig(opts) + if err != nil { + return nil, err } - - if opts.RESTClientGetter == nil { - conf, err := clientcmd.BuildConfigFromFlags("", opts.KubeConfigPath) - if err != nil { - return nil, fmt.Errorf("unable to process kubernetes config for kube client: %w", err) - } - restConfig = conf - } else { - conf, err := opts.RESTClientGetter.ToRESTConfig() - if err != nil { - return nil, fmt.Errorf("unable to process rest client config for kube client: %w", err) - } - restConfig = conf - } - return client.New(restConfig, client.Options{Scheme: getScheme()}) } // NewMetadataClient returns a new kube metadata client. func NewMetadataClient(opts KubeClientOptions) (metadata.Interface, error) { - var restConfig *rest.Config - if opts.RESTClientGetter == nil && opts.KubeConfigPath == "" { - return nil, fmt.Errorf("a valid kube config is required to create a kube client") + restConfig, err := getRESTConfig(opts) + if err != nil { + return nil, err } + return metadata.NewForConfig(restConfig) +} - if opts.RESTClientGetter == nil { - conf, err := clientcmd.BuildConfigFromFlags("", opts.KubeConfigPath) +// NewDiscoveryClient returns a new kube discovery client. +func NewDiscoveryClient(opts KubeClientOptions) (discovery.DiscoveryInterface, error) { + restConfig, err := getRESTConfig(opts) + if err != nil { + return nil, err + } + return discovery.NewDiscoveryClientForConfig(restConfig) +} + +func getRESTConfig(opts KubeClientOptions) (*rest.Config, error) { + if opts.RESTClientGetter != nil { + conf, err := opts.RESTClientGetter.ToRESTConfig() if err != nil { - return nil, fmt.Errorf("unable to process kubernetes config for kube client: %w", err) + return nil, fmt.Errorf("invalid rest client getter: %w", err) } - restConfig = conf - } else { - conf, err := opts.RESTClientGetter.ToRESTConfig() + return conf, nil + } + + if opts.KubeConfigPath != "" { + conf, err := clientcmd.BuildConfigFromFlags("", opts.KubeConfigPath) if err != nil { - return nil, fmt.Errorf("unable to process rest client config for kube client: %w", err) + return nil, fmt.Errorf("invalid kubeconfig path: %w", err) } - restConfig = conf + return conf, nil } - return metadata.NewForConfig(restConfig) + return nil, fmt.Errorf("a valid kube config is required to create a kube client") } diff --git a/api/internal/clients/kube_test.go b/api/internal/clients/kube_test.go index 057e86578f..3dfa2f9ef7 100644 --- a/api/internal/clients/kube_test.go +++ b/api/internal/clients/kube_test.go @@ -117,7 +117,7 @@ func TestNewKubeClient(t *testing.T) { } }, expectedError: true, - errorContains: "unable to process kubernetes config for kube client", + errorContains: "invalid kubeconfig path", }, { name: "error with invalid kubeconfig content", @@ -128,7 +128,7 @@ func TestNewKubeClient(t *testing.T) { } }, expectedError: true, - errorContains: "unable to process kubernetes config for kube client", + errorContains: "invalid kubeconfig path", }, { name: "error with RESTClientGetter returning error", @@ -140,7 +140,7 @@ func TestNewKubeClient(t *testing.T) { } }, expectedError: true, - errorContains: "unable to process rest client config for kube client", + errorContains: "invalid rest client getter", }, } @@ -214,7 +214,7 @@ func TestNewMetadataClient(t *testing.T) { } }, expectedError: true, - errorContains: "unable to process kubernetes config for kube client", + errorContains: "invalid kubeconfig path", }, { name: "error with invalid kubeconfig content", @@ -225,7 +225,7 @@ func TestNewMetadataClient(t *testing.T) { } }, expectedError: true, - errorContains: "unable to process kubernetes config for kube client", + errorContains: "invalid kubeconfig path", }, { name: "error with RESTClientGetter returning error", @@ -237,7 +237,7 @@ func TestNewMetadataClient(t *testing.T) { } }, expectedError: true, - errorContains: "unable to process rest client config for kube client", + errorContains: "invalid rest client getter", }, } diff --git a/api/internal/handlers/kubernetes/install.go b/api/internal/handlers/kubernetes/install.go index 911c638ea2..e59df0393d 100644 --- a/api/internal/handlers/kubernetes/install.go +++ b/api/internal/handlers/kubernetes/install.go @@ -97,6 +97,8 @@ func (h *Handler) PostRunAppPreflights(w http.ResponseWriter, r *http.Request) { return } + // TODO: support registry settings + err = h.installController.RunAppPreflights(r.Context(), appinstall.RunAppPreflightOptions{ PreflightBinaryPath: preflightBinary, ProxySpec: h.cfg.Installation.ProxySpec(), @@ -300,7 +302,13 @@ func (h *Handler) PostInstallApp(w http.ResponseWriter, r *http.Request) { return } - err := h.installController.InstallApp(r.Context(), req.IgnoreAppPreflights) + // TODO: support registry settings + + err := h.installController.InstallApp(r.Context(), appinstall.InstallAppOptions{ + IgnoreAppPreflights: req.IgnoreAppPreflights, + ProxySpec: h.cfg.Installation.ProxySpec(), + RegistrySettings: nil, + }) if err != nil { utils.LogError(r, err, h.logger, "failed to install app") utils.JSONError(w, r, err, h.logger) diff --git a/api/internal/handlers/kubernetes/kubernetes.go b/api/internal/handlers/kubernetes/kubernetes.go index 1a48538e07..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, @@ -55,13 +63,14 @@ func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { installController, err := install.NewInstallController( install.WithLogger(h.logger), install.WithMetricsReporter(h.metricsReporter), - 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/linux/install.go b/api/internal/handlers/linux/install.go index 3c789fd3a3..d74124bd6b 100644 --- a/api/internal/handlers/linux/install.go +++ b/api/internal/handlers/linux/install.go @@ -380,7 +380,18 @@ func (h *Handler) PostInstallApp(w http.ResponseWriter, r *http.Request) { return } - err := h.installController.InstallApp(r.Context(), req.IgnoreAppPreflights) + registrySettings, err := h.installController.CalculateRegistrySettings(r.Context()) + if err != nil { + utils.LogError(r, err, h.logger, "failed to calculate registry settings") + utils.JSONError(w, r, err, h.logger) + return + } + + err = h.installController.InstallApp(r.Context(), appinstall.InstallAppOptions{ + IgnoreAppPreflights: req.IgnoreAppPreflights, + ProxySpec: h.cfg.RuntimeConfig.ProxySpec(), + RegistrySettings: registrySettings, + }) if err != nil { utils.LogError(r, err, h.logger, "failed to install app") utils.JSONError(w, r, err, h.logger) 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 a239666bf8..333b9c8faa 100644 --- a/api/internal/managers/app/install/install.go +++ b/api/internal/managers/app/install/install.go @@ -10,13 +10,18 @@ import ( "github.com/replicatedhq/embedded-cluster/api/types" kotscli "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" "github.com/replicatedhq/embedded-cluster/pkg-new/constants" + "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/netutils" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" kyaml "sigs.k8s.io/yaml" ) -// Install installs the app with the provided config values -func (m *appInstallManager) Install(ctx context.Context, configValues kotsv1beta1.ConfigValues) (finalErr error) { +// Install installs the app with the provided installable Helm charts and config values +func (m *appInstallManager) Install(ctx context.Context, installableCharts []types.InstallableHelmChart, kotsConfigValues kotsv1beta1.ConfigValues) (finalErr error) { + if err := m.initializeComponents(installableCharts); err != nil { + return fmt.Errorf("initialize components: %w", err) + } + if err := m.setStatus(types.StateRunning, "Installing application"); err != nil { return fmt.Errorf("set status: %w", err) } @@ -36,14 +41,27 @@ func (m *appInstallManager) Install(ctx context.Context, configValues kotsv1beta } }() - if err := m.install(ctx, configValues); err != nil { + if err := m.install(ctx, installableCharts, kotsConfigValues); err != nil { return err } return nil } -func (m *appInstallManager) install(ctx context.Context, configValues kotsv1beta1.ConfigValues) error { +func (m *appInstallManager) install(ctx context.Context, installableCharts []types.InstallableHelmChart, kotsConfigValues kotsv1beta1.ConfigValues) error { + if err := m.installKots(kotsConfigValues); err != nil { + return fmt.Errorf("install kots: %w", err) + } + + // Install Helm charts + if err := m.installHelmCharts(ctx, installableCharts); err != nil { + return fmt.Errorf("install helm charts: %w", err) + } + + return nil +} + +func (m *appInstallManager) installKots(kotsConfigValues kotsv1beta1.ConfigValues) error { license := &kotsv1beta1.License{} if err := kyaml.Unmarshal(m.license, license); err != nil { return fmt.Errorf("parse license: %w", err) @@ -52,34 +70,47 @@ func (m *appInstallManager) install(ctx context.Context, configValues kotsv1beta ecDomains := utils.GetDomains(m.releaseData) installOpts := kotscli.InstallOptions{ - AppSlug: license.Spec.AppSlug, - License: m.license, - Namespace: constants.KotsadmNamespace, - ClusterID: m.clusterID, - AirgapBundle: m.airgapBundle, - // Skip running the KOTS app preflights in the Admin Console; they run in the manager experience installer when ENABLE_V3 is enabled - SkipPreflights: os.Getenv("ENABLE_V3") == "1", + AppSlug: license.Spec.AppSlug, + License: m.license, + Namespace: constants.KotsadmNamespace, + ClusterID: m.clusterID, + AirgapBundle: m.airgapBundle, + SkipPreflights: true, ReplicatedAppEndpoint: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), Stdout: m.newLogWriter(), } - configValuesFile, err := m.createConfigValuesFile(configValues) + configValuesFile, err := m.createConfigValuesFile(kotsConfigValues) if err != nil { return fmt.Errorf("creating config values file: %w", err) } installOpts.ConfigValuesFile = configValuesFile + logFn := m.logFn("app") + + logFn("preparing the app for installation") + if m.kotsCLI != nil { - return m.kotsCLI.Install(installOpts) + err := m.kotsCLI.Install(installOpts) + if err != nil { + return fmt.Errorf("install kots: %w", err) + } + } else { + err := kotscli.Install(installOpts) + if err != nil { + return fmt.Errorf("install kots: %w", err) + } } - return kotscli.Install(installOpts) + logFn("successfully prepared the app for installation") + + return nil } // createConfigValuesFile creates a temporary file with the config values -func (m *appInstallManager) createConfigValuesFile(configValues kotsv1beta1.ConfigValues) (string, error) { +func (m *appInstallManager) createConfigValuesFile(kotsConfigValues kotsv1beta1.ConfigValues) (string, error) { // Use Kubernetes-specific YAML serialization to properly handle TypeMeta and ObjectMeta - data, err := kyaml.Marshal(configValues) + data, err := kyaml.Marshal(kotsConfigValues) if err != nil { return "", fmt.Errorf("marshaling config values: %w", err) } @@ -97,3 +128,99 @@ func (m *appInstallManager) createConfigValuesFile(configValues kotsv1beta1.Conf return configValuesFile.Name(), nil } + +func (m *appInstallManager) installHelmCharts(ctx context.Context, installableCharts []types.InstallableHelmChart) error { + logFn := m.logFn("app") + + if len(installableCharts) == 0 { + return fmt.Errorf("no helm charts found") + } + + logFn("installing %d helm charts", len(installableCharts)) + + for _, installableChart := range installableCharts { + chartName := getChartDisplayName(installableChart) + logFn("installing %s chart", chartName) + + if err := m.installHelmChart(ctx, installableChart); err != nil { + return fmt.Errorf("install %s helm chart: %w", chartName, err) + } + + logFn("successfully installed %s chart", chartName) + } + + logFn("successfully installed all %d helm charts", len(installableCharts)) + + return nil +} + +func (m *appInstallManager) installHelmChart(ctx context.Context, installableChart types.InstallableHelmChart) (finalErr error) { + chartName := getChartDisplayName(installableChart) + + if err := m.setComponentStatus(chartName, types.StateRunning, "Installing"); err != nil { + return fmt.Errorf("set component status: %w", err) + } + + defer func() { + if r := recover(); r != nil { + finalErr = fmt.Errorf("recovered from panic: %v: %s", r, string(debug.Stack())) + } + + if finalErr != nil { + if err := m.setComponentStatus(chartName, types.StateFailed, finalErr.Error()); err != nil { + m.logger.WithError(err).Errorf("failed to set %s chart failed status", chartName) + } + } else { + if err := m.setComponentStatus(chartName, types.StateSucceeded, ""); err != nil { + m.logger.WithError(err).Errorf("failed to set %s chart succeeded status", chartName) + } + } + }() + + // Write chart archive to temp file + chartPath, err := m.writeChartArchiveToTemp(installableChart.Archive) + if err != nil { + return fmt.Errorf("write chart archive to temp: %w", err) + } + defer os.Remove(chartPath) + + // Fallback to admin console namespace if namespace is not set + namespace := installableChart.CR.GetNamespace() + if namespace == "" { + namespace = constants.KotsadmNamespace + } + + // Install chart using Helm client with pre-processed values + _, err = m.hcli.Install(ctx, helm.InstallOptions{ + ChartPath: chartPath, + 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" + } + + return nil +} + +// initializeComponents initializes the component tracking with chart names +func (m *appInstallManager) initializeComponents(charts []types.InstallableHelmChart) error { + chartNames := make([]string, 0, len(charts)) + for _, chart := range charts { + chartNames = append(chartNames, getChartDisplayName(chart)) + } + + return m.appInstallStore.RegisterComponents(chartNames) +} + +// getChartDisplayName returns the name of the chart for display purposes. It prefers the +// metadata.name field if available and falls back to the chart name. +func getChartDisplayName(chart types.InstallableHelmChart) string { + chartName := chart.CR.GetName() + if chartName == "" { + chartName = chart.CR.GetChartName() + } + return chartName +} diff --git a/api/internal/managers/app/install/install_test.go b/api/internal/managers/app/install/install_test.go index ce3d944251..2db7e43bb0 100644 --- a/api/internal/managers/app/install/install_test.go +++ b/api/internal/managers/app/install/install_test.go @@ -1,7 +1,12 @@ package install import ( + "archive/tar" + "bytes" + "compress/gzip" "context" + "errors" + "fmt" "os" "testing" "time" @@ -10,11 +15,14 @@ import ( "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" kotscli "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" + "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" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kyaml "sigs.k8s.io/yaml" ) @@ -28,8 +36,12 @@ func TestAppInstallManager_Install(t *testing.T) { licenseBytes, err := kyaml.Marshal(license) require.NoError(t, err) - // Create test release data + // Create valid helm chart archive + mockChartArchive := createTestChartArchive(t, "test-chart", "0.1.0") + + // Create test release data with helm chart archives releaseData := &release.ReleaseData{ + HelmChartArchives: [][]byte{mockChartArchive}, ChannelRelease: &release.ChannelRelease{ DefaultDomains: release.Domains{ ReplicatedAppDomain: "replicated.app", @@ -37,8 +49,8 @@ func TestAppInstallManager_Install(t *testing.T) { }, } - t.Run("Config values should be passed to the installer", func(t *testing.T) { - configValues := kotsv1beta1.ConfigValues{ + t.Run("Success", func(t *testing.T) { + kotsConfigValues := kotsv1beta1.ConfigValues{ Spec: kotsv1beta1.ConfigValuesSpec{ Values: map[string]kotsv1beta1.ConfigValue{ "key1": { @@ -51,7 +63,63 @@ func TestAppInstallManager_Install(t *testing.T) { }, } - // Create mock installer with detailed verification + // Create InstallableHelmCharts with weights - should already be sorted at this stage + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "nginx-chart", "1.0.0", "web-server", "web", 10, map[string]any{ + "image": map[string]any{ + "repository": "nginx", + "tag": "latest", + }, + "replicas": 3, + }), + createTestInstallableHelmChart(t, "postgres-chart", "2.0.0", "database", "data", 20, map[string]any{ + "database": map[string]any{ + "host": "postgres.example.com", + "password": "secret", + }, + }), + } + + // Create mock helm client that validates pre-processed values + mockHelmClient := &helm.MockClient{} + + // Chart 1 installation (nginx chart) + nginxCall := mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { + if opts.ReleaseName != "web-server" { + return false + } + if opts.Namespace != "web" { + return false + } + // Check if values contain expected pre-processed data for nginx chart + if vals, ok := opts.Values["image"].(map[string]any); ok { + return vals["repository"] == "nginx" && vals["tag"] == "latest" && opts.Values["replicas"] == 3 + } + return false + })).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 { + if opts.ReleaseName != "database" { + return false + } + if opts.Namespace != "data" { + return false + } + // Check if values contain expected pre-processed database data + if vals, ok := opts.Values["database"].(map[string]any); ok { + return vals["host"] == "postgres.example.com" && vals["password"] == "secret" + } + return false + })).Return("Release \"database\" has been installed.", nil) + + // Verify installation order + mock.InOrder( + nginxCall, + databaseCall, + ) + + // Create mock installer with detailed verification of config values mockInstaller := &MockKotsCLIInstaller{} mockInstaller.On("Install", mock.MatchedBy(func(opts kotscli.InstallOptions) bool { // Verify basic install options @@ -113,18 +181,30 @@ func TestAppInstallManager_Install(t *testing.T) { WithAirgapBundle("test-airgap.tar.gz"), WithReleaseData(releaseData), WithKotsCLI(mockInstaller), + WithHelmClient(mockHelmClient), WithLogger(logger.NewDiscardLogger()), ) require.NoError(t, err) - // Run installation - err = manager.Install(context.Background(), configValues) + // Run installation with InstallableHelmCharts and config values + err = manager.Install(context.Background(), installableCharts, kotsConfigValues) require.NoError(t, err) mockInstaller.AssertExpectations(t) + mockHelmClient.AssertExpectations(t) }) t.Run("Install updates status correctly", func(t *testing.T) { + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "monitoring-chart", "1.0.0", "prometheus", "monitoring", 0, map[string]any{"key": "value"}), + } + + // Create mock helm client + 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("Release \"prometheus\" has been installed.", nil) + // Create mock installer that succeeds mockInstaller := &MockKotsCLIInstaller{} mockInstaller.On("Install", mock.Anything).Return(nil) @@ -138,6 +218,7 @@ func TestAppInstallManager_Install(t *testing.T) { WithClusterID("test-cluster"), WithReleaseData(releaseData), WithKotsCLI(mockInstaller), + WithHelmClient(mockHelmClient), WithLogger(logger.NewDiscardLogger()), WithAppInstallStore(store), ) @@ -149,7 +230,7 @@ func TestAppInstallManager_Install(t *testing.T) { assert.Equal(t, types.StatePending, appInstall.Status.State) // Run installation - err = manager.Install(context.Background(), kotsv1beta1.ConfigValues{}) + err = manager.Install(context.Background(), installableCharts, kotsv1beta1.ConfigValues{}) require.NoError(t, err) // Verify final status @@ -159,12 +240,23 @@ func TestAppInstallManager_Install(t *testing.T) { assert.Equal(t, "Installation complete", appInstall.Status.Description) mockInstaller.AssertExpectations(t) + mockHelmClient.AssertExpectations(t) }) t.Run("Install handles errors correctly", func(t *testing.T) { - // Create mock installer that fails + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "logging-chart", "1.0.0", "fluentd", "logging", 0, map[string]any{"key": "value"}), + } + + // Create mock helm client that fails + 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("", assert.AnError) + + // Create mock installer that succeeds (so we get to Helm charts) mockInstaller := &MockKotsCLIInstaller{} - mockInstaller.On("Install", mock.Anything).Return(assert.AnError) + mockInstaller.On("Install", mock.Anything).Return(nil) // Create manager with initialized store store := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{ @@ -175,22 +267,24 @@ func TestAppInstallManager_Install(t *testing.T) { WithClusterID("test-cluster"), WithReleaseData(releaseData), WithKotsCLI(mockInstaller), + WithHelmClient(mockHelmClient), WithLogger(logger.NewDiscardLogger()), WithAppInstallStore(store), ) require.NoError(t, err) // Run installation (should fail) - err = manager.Install(context.Background(), kotsv1beta1.ConfigValues{}) + err = manager.Install(context.Background(), installableCharts, kotsv1beta1.ConfigValues{}) assert.Error(t, err) // Verify final status appInstall, err := manager.GetStatus() require.NoError(t, err) assert.Equal(t, types.StateFailed, appInstall.Status.State) - assert.Equal(t, assert.AnError.Error(), appInstall.Status.Description) + assert.Contains(t, appInstall.Status.Description, "install helm charts") mockInstaller.AssertExpectations(t) + mockHelmClient.AssertExpectations(t) }) t.Run("GetStatus returns current app install state", func(t *testing.T) { @@ -208,6 +302,7 @@ func TestAppInstallManager_Install(t *testing.T) { manager, err := NewAppInstallManager( WithLogger(logger.NewDiscardLogger()), WithAppInstallStore(store), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -218,12 +313,13 @@ func TestAppInstallManager_Install(t *testing.T) { assert.Equal(t, "Installing application", appInstall.Status.Description) assert.Equal(t, "Installation started\n", appInstall.Logs) }) + } func TestAppInstallManager_createConfigValuesFile(t *testing.T) { manager := &appInstallManager{} - configValues := kotsv1beta1.ConfigValues{ + kotsConfigValues := kotsv1beta1.ConfigValues{ Spec: kotsv1beta1.ConfigValuesSpec{ Values: map[string]kotsv1beta1.ConfigValue{ "testKey": { @@ -233,7 +329,7 @@ func TestAppInstallManager_createConfigValuesFile(t *testing.T) { }, } - filename, err := manager.createConfigValuesFile(configValues) + filename, err := manager.createConfigValuesFile(kotsConfigValues) assert.NoError(t, err) assert.NotEmpty(t, filename) @@ -249,3 +345,192 @@ func TestAppInstallManager_createConfigValuesFile(t *testing.T) { // Clean up os.Remove(filename) } + +// createTarGzArchive creates a tar.gz archive with the given files +func createTarGzArchive(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for filename, content := range files { + header := &tar.Header{ + Name: filename, + Mode: 0600, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(header)) + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + } + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + return buf.Bytes() +} + +func createTestChartArchive(t *testing.T, name, version string) []byte { + chartYaml := fmt.Sprintf(`apiVersion: v2 +name: %s +version: %s +description: A test Helm chart +type: application +`, name, version) + + return createTarGzArchive(t, map[string]string{ + fmt.Sprintf("%s/Chart.yaml", name): chartYaml, + }) +} + +// Helper functions to create test data that can be reused across test cases +func createTestHelmChartCR(name, releaseName, namespace string, weight int64) *kotsv1beta2.HelmChart { + return &kotsv1beta2.HelmChart{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kots.io/v1beta2", + Kind: "HelmChart", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: kotsv1beta2.HelmChartSpec{ + Chart: kotsv1beta2.ChartIdentifier{ + Name: name, + ChartVersion: "1.0.0", + }, + ReleaseName: releaseName, + Namespace: namespace, + Weight: weight, + }, + } +} + +func createTestInstallableHelmChart(t *testing.T, chartName, chartVersion, releaseName, namespace string, weight int64, values map[string]any) types.InstallableHelmChart { + return types.InstallableHelmChart{ + Archive: createTestChartArchive(t, chartName, chartVersion), + Values: values, + CR: createTestHelmChartCR(chartName, releaseName, namespace, weight), + } +} + +// TestComponentStatusTracking tests that components are properly initialized and tracked +func TestComponentStatusTracking(t *testing.T) { + t.Run("Components are registered and status is tracked", func(t *testing.T) { + // Create test charts with different weights + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "database-chart", "1.0.0", "postgres", "data", 10, map[string]any{"key": "value1"}), + createTestInstallableHelmChart(t, "web-chart", "2.0.0", "nginx", "web", 20, map[string]any{"key": "value2"}), + } + + // Create mock helm client + mockHelmClient := &helm.MockClient{} + + // 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("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("Release \"nginx\" has been installed.", nil).Once() + + // Create mock KOTS installer + mockInstaller := &MockKotsCLIInstaller{} + mockInstaller.On("Install", mock.Anything).Return(nil) + + // Create manager with in-memory store + appInstallStore := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{ + Status: types.Status{State: types.StatePending}, + })) + manager, err := NewAppInstallManager( + WithAppInstallStore(appInstallStore), + WithReleaseData(&release.ReleaseData{}), + WithLicense([]byte(`{"spec":{"appSlug":"test-app"}}`)), + WithClusterID("test-cluster"), + WithKotsCLI(mockInstaller), + WithHelmClient(mockHelmClient), + ) + require.NoError(t, err) + + // Install the charts + err = manager.Install(context.Background(), installableCharts, kotsv1beta1.ConfigValues{}) + require.NoError(t, err) + + // Verify that components were registered and have correct status + appInstall, err := manager.GetStatus() + require.NoError(t, err) + + // Should have 2 components + assert.Len(t, appInstall.Components, 2, "Should have 2 components") + + // Components should be sorted by weight (database first with weight 10, web second with weight 20) + assert.Equal(t, "database-chart", appInstall.Components[0].Name) + assert.Equal(t, types.StateSucceeded, appInstall.Components[0].Status.State) + + assert.Equal(t, "web-chart", appInstall.Components[1].Name) + assert.Equal(t, types.StateSucceeded, appInstall.Components[1].Status.State) + + // Overall status should be succeeded + assert.Equal(t, types.StateSucceeded, appInstall.Status.State) + assert.Equal(t, "Installation complete", appInstall.Status.Description) + + mockInstaller.AssertExpectations(t) + mockHelmClient.AssertExpectations(t) + }) + + t.Run("Component failure is tracked correctly", func(t *testing.T) { + // Create test chart + installableCharts := []types.InstallableHelmChart{ + createTestInstallableHelmChart(t, "failing-chart", "1.0.0", "failing-app", "default", 0, map[string]any{"key": "value"}), + } + + // Create mock helm client that fails + mockHelmClient := &helm.MockClient{} + mockHelmClient.On("Install", mock.Anything, mock.MatchedBy(func(opts helm.InstallOptions) bool { + return opts.ReleaseName == "failing-app" + })).Return("", errors.New("helm install failed")) + + // Create mock installer that succeeds (so we get to Helm charts) + mockInstaller := &MockKotsCLIInstaller{} + mockInstaller.On("Install", mock.Anything).Return(nil) + + // Create manager with in-memory store + appInstallStore := appinstallstore.NewMemoryStore(appinstallstore.WithAppInstall(types.AppInstall{ + Status: types.Status{State: types.StatePending}, + })) + manager, err := NewAppInstallManager( + WithAppInstallStore(appInstallStore), + WithReleaseData(&release.ReleaseData{}), + WithLicense([]byte(`{"spec":{"appSlug":"test-app"}}`)), + WithClusterID("test-cluster"), + WithKotsCLI(mockInstaller), + WithHelmClient(mockHelmClient), + ) + require.NoError(t, err) + + // Install the charts (should fail) + err = manager.Install(context.Background(), installableCharts, kotsv1beta1.ConfigValues{}) + require.Error(t, err) + + // Verify that component failure is tracked + appInstall, err := manager.GetStatus() + require.NoError(t, err) + + // Should have 1 component + assert.Len(t, appInstall.Components, 1, "Should have 1 component") + + // Component should be marked as failed + failedComponent := appInstall.Components[0] + assert.Equal(t, "failing-chart", failedComponent.Name) + assert.Equal(t, types.StateFailed, failedComponent.Status.State) + assert.Contains(t, failedComponent.Status.Description, "helm install failed") + + // Overall status should be failed + assert.Equal(t, types.StateFailed, appInstall.Status.State) + + mockInstaller.AssertExpectations(t) + mockHelmClient.AssertExpectations(t) + }) +} diff --git a/api/internal/managers/app/install/manager.go b/api/internal/managers/app/install/manager.go index 69f498a613..b064f01e03 100644 --- a/api/internal/managers/app/install/manager.go +++ b/api/internal/managers/app/install/manager.go @@ -2,11 +2,13 @@ package install import ( "context" + "fmt" appinstallstore "github.com/replicatedhq/embedded-cluster/api/internal/store/app/install" "github.com/replicatedhq/embedded-cluster/api/pkg/logger" "github.com/replicatedhq/embedded-cluster/api/types" kotscli "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" + "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" @@ -21,8 +23,8 @@ type KotsCLIInstaller interface { // AppInstallManager provides methods for managing app installation type AppInstallManager interface { - // Install installs the app with the provided config values - Install(ctx context.Context, configValues kotsv1beta1.ConfigValues) error + // Install installs the app with the provided installable Helm charts and config values + Install(ctx context.Context, installableCharts []types.InstallableHelmChart, kotsConfigValues kotsv1beta1.ConfigValues) error // GetStatus returns the current app installation status GetStatus() (types.AppInstall, error) } @@ -36,6 +38,7 @@ type appInstallManager struct { airgapBundle string kotsCLI KotsCLIInstaller logger logrus.FieldLogger + hcli helm.Client } type AppInstallManagerOption func(*appInstallManager) @@ -82,6 +85,13 @@ func WithKotsCLI(kotsCLI KotsCLIInstaller) AppInstallManagerOption { } } +// Add constructor options following infra manager pattern +func WithHelmClient(hcli helm.Client) AppInstallManagerOption { + return func(m *appInstallManager) { + m.hcli = hcli + } +} + // NewAppInstallManager creates a new AppInstallManager with the provided options func NewAppInstallManager(opts ...AppInstallManagerOption) (*appInstallManager, error) { manager := &appInstallManager{} @@ -94,6 +104,10 @@ func NewAppInstallManager(opts ...AppInstallManagerOption) (*appInstallManager, 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/mock.go b/api/internal/managers/app/install/mock.go index 221845e53c..6d927dfe4f 100644 --- a/api/internal/managers/app/install/mock.go +++ b/api/internal/managers/app/install/mock.go @@ -15,8 +15,8 @@ type MockAppInstallManager struct { } // Install mocks the Install method -func (m *MockAppInstallManager) Install(ctx context.Context, configValues kotsv1beta1.ConfigValues) error { - args := m.Called(ctx, configValues) +func (m *MockAppInstallManager) Install(ctx context.Context, installableCharts []types.InstallableHelmChart, kotsConfigValues kotsv1beta1.ConfigValues) error { + args := m.Called(ctx, installableCharts, kotsConfigValues) return args.Error(0) } diff --git a/api/internal/managers/app/install/status.go b/api/internal/managers/app/install/status.go index 55f9da21e9..5cfc5bc412 100644 --- a/api/internal/managers/app/install/status.go +++ b/api/internal/managers/app/install/status.go @@ -1,7 +1,6 @@ package install import ( - "fmt" "time" "github.com/replicatedhq/embedded-cluster/api/types" @@ -19,9 +18,10 @@ func (m *appInstallManager) setStatus(state types.State, description string) err }) } -func (m *appInstallManager) addLogs(format string, v ...interface{}) { - msg := fmt.Sprintf(format, v...) - if err := m.appInstallStore.AddLogs(msg); err != nil { - m.logger.WithError(err).Error("add log") - } +func (m *appInstallManager) setComponentStatus(componentName string, state types.State, description string) error { + return m.appInstallStore.SetComponentStatus(componentName, types.Status{ + State: state, + Description: description, + LastUpdated: time.Now(), + }) } diff --git a/api/internal/managers/app/install/util.go b/api/internal/managers/app/install/util.go index 4c18e6bb4f..1ac4312cd8 100644 --- a/api/internal/managers/app/install/util.go +++ b/api/internal/managers/app/install/util.go @@ -1,7 +1,10 @@ package install import ( + "fmt" "io" + "os" + "regexp" "strings" ) @@ -14,10 +17,45 @@ func (m *appInstallManager) newLogWriter() io.Writer { return &logWriter{manager: m} } +// ANSI escape sequence regex pattern +var ansiEscapeRegex = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`) + func (lw *logWriter) Write(p []byte) (n int, err error) { - output := strings.TrimSpace(string(p)) + output := string(p) + // Strip ANSI escape sequences + output = ansiEscapeRegex.ReplaceAllString(output, "") + output = strings.TrimSpace(output) if output != "" { - lw.manager.addLogs("[kots] %s", output) + lw.manager.addLogs("kots", "%s", output) } return len(p), 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...) + m.addLogs(component, format, v...) + } +} + +func (m *appInstallManager) addLogs(component string, format string, v ...interface{}) { + msg := fmt.Sprintf("[%s] %s", component, fmt.Sprintf(format, v...)) + if err := m.appInstallStore.AddLogs(msg); err != nil { + m.logger.WithError(err).Error("add log") + } +} + +func (m *appInstallManager) writeChartArchiveToTemp(chartArchive []byte) (string, error) { + tmpFile, err := os.CreateTemp("", "helm-chart-*.tgz") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + defer tmpFile.Close() + + if _, err := tmpFile.Write(chartArchive); err != nil { + _ = os.Remove(tmpFile.Name()) + return "", fmt.Errorf("write chart archive: %w", err) + } + + return tmpFile.Name(), nil +} diff --git a/api/internal/managers/app/release/manager.go b/api/internal/managers/app/release/manager.go index a8326d02b9..6b1dab542d 100644 --- a/api/internal/managers/app/release/manager.go +++ b/api/internal/managers/app/release/manager.go @@ -8,6 +8,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/pkg/template" "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" @@ -17,6 +18,7 @@ import ( // AppReleaseManager provides methods for managing the release of an app type AppReleaseManager interface { ExtractAppPreflightSpec(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) (*troubleshootv1beta2.PreflightSpec, error) + ExtractInstallableHelmCharts(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) ([]types.InstallableHelmChart, error) } type appReleaseManager struct { @@ -26,6 +28,7 @@ type appReleaseManager struct { license *kotsv1beta1.License logger logrus.FieldLogger privateCACertConfigMapName string + hcli helm.Client } type AppReleaseManagerOption func(*appReleaseManager) @@ -60,6 +63,12 @@ func WithPrivateCACertConfigMapName(configMapName string) AppReleaseManagerOptio } } +func WithHelmClient(hcli helm.Client) AppReleaseManagerOption { + return func(m *appReleaseManager) { + m.hcli = hcli + } +} + // NewAppReleaseManager creates a new AppReleaseManager func NewAppReleaseManager(config kotsv1beta1.Config, opts ...AppReleaseManagerOption) (AppReleaseManager, error) { manager := &appReleaseManager{ @@ -74,6 +83,10 @@ func NewAppReleaseManager(config kotsv1beta1.Config, opts ...AppReleaseManagerOp return nil, fmt.Errorf("release data not found") } + if manager.hcli == nil { + return nil, fmt.Errorf("helm client is required") + } + if manager.logger == nil { manager.logger = logger.NewDiscardLogger() } diff --git a/api/internal/managers/app/release/manager_mock.go b/api/internal/managers/app/release/manager_mock.go index 4bdeb2652e..941e72ac2f 100644 --- a/api/internal/managers/app/release/manager_mock.go +++ b/api/internal/managers/app/release/manager_mock.go @@ -24,3 +24,12 @@ func (m *MockAppReleaseManager) ExtractAppPreflightSpec(ctx context.Context, con } return args.Get(0).(*troubleshootv1beta2.PreflightSpec), args.Error(1) } + +// ExtractInstallableHelmCharts mocks the ExtractInstallableHelmCharts method +func (m *MockAppReleaseManager) ExtractInstallableHelmCharts(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) ([]types.InstallableHelmChart, error) { + args := m.Called(ctx, configValues, proxySpec, registrySettings) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]types.InstallableHelmChart), args.Error(1) +} diff --git a/api/internal/managers/app/release/template.go b/api/internal/managers/app/release/template.go index d381ad2f09..88ae81db52 100644 --- a/api/internal/managers/app/release/template.go +++ b/api/internal/managers/app/release/template.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "os" + "sort" "strconv" "github.com/replicatedhq/embedded-cluster/api/pkg/template" @@ -56,6 +57,59 @@ func (m *appReleaseManager) ExtractAppPreflightSpec(ctx context.Context, configV return mergedSpec, nil } +// ExtractInstallableHelmCharts extracts and processes installable Helm charts from app releases +func (m *appReleaseManager) ExtractInstallableHelmCharts(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) ([]types.InstallableHelmChart, error) { + // Template Helm chart CRs with config values + templatedCRs, err := m.templateHelmChartCRs(configValues, proxySpec, registrySettings) + if err != nil { + return nil, fmt.Errorf("template helm chart CRs: %w", err) + } + + var installableCharts []types.InstallableHelmChart + + // Iterate over each templated CR and create installable chart with processed values + for _, cr := range templatedCRs { + // Check if the chart should be excluded + if !cr.Spec.Exclude.IsEmpty() { + exclude, err := cr.Spec.Exclude.Boolean() + if err != nil { + return nil, fmt.Errorf("parse templated CR exclude for %s: %w", cr.Name, err) + } + if exclude { + continue + } + } + + // Find the corresponding chart archive for this HelmChart CR + chartArchive, err := findChartArchive(m.releaseData.HelmChartArchives, cr) + if err != nil { + return nil, fmt.Errorf("find chart archive for %s: %w", cr.Name, err) + } + + // Generate Helm values from the templated CR + values, err := generateHelmValues(cr) + if err != nil { + return nil, fmt.Errorf("generate helm values for chart %s: %w", cr.Name, err) + } + + // Create installable chart with archive, processed values, and CR + installableChart := types.InstallableHelmChart{ + Archive: chartArchive, + Values: values, + CR: cr, + } + + installableCharts = append(installableCharts, installableChart) + } + + // Sort charts by weight field before returning + sort.Slice(installableCharts, func(i, j int) bool { + return installableCharts[i].CR.GetWeight() < installableCharts[j].CR.GetWeight() + }) + + return installableCharts, nil +} + // templateHelmChartCRs templates the HelmChart CRs from release data using the template engine and config values func (m *appReleaseManager) templateHelmChartCRs(configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec, registrySettings *types.RegistrySettings) ([]*kotsv1beta2.HelmChart, error) { if m.templateEngine == nil { @@ -131,13 +185,6 @@ func (m *appReleaseManager) dryRunHelmChart(ctx context.Context, templatedCR *ko return nil, fmt.Errorf("generate helm values for %s: %w", templatedCR.Name, err) } - // Create a Helm client for dry run templating - helmClient, err := helm.NewClient(helm.HelmOptions{}) - if err != nil { - return nil, fmt.Errorf("create helm client: %w", err) - } - defer helmClient.Close() - // Write chart archive to a temporary file chartPath, err := writeChartArchiveToTemp(chartArchive) if err != nil { @@ -161,7 +208,8 @@ func (m *appReleaseManager) dryRunHelmChart(ctx context.Context, templatedCR *ko } // Perform dry run rendering - manifests, err := helmClient.Render(ctx, installOpts) + + manifests, err := m.hcli.Render(ctx, installOpts) if err != nil { return nil, fmt.Errorf("render helm chart %s: %w", templatedCR.Name, err) } @@ -176,7 +224,7 @@ func generateHelmValues(templatedCR *kotsv1beta2.HelmChart) (map[string]any, err } // Start with the base values - mergedValues := templatedCR.Spec.Values + mergedValues := maps.Clone(templatedCR.Spec.Values) if mergedValues == nil { mergedValues = map[string]kotsv1beta2.MappedChartValue{} } diff --git a/api/internal/managers/app/release/template_test.go b/api/internal/managers/app/release/template_test.go index 5170922f95..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" @@ -34,7 +35,6 @@ func TestAppReleaseManager_ExtractAppPreflightSpec(t *testing.T) { name: "no helm charts returns nil", helmChartCRs: [][]byte{}, configValues: types.AppConfigValues{}, - proxySpec: &ecv1beta1.ProxySpec{}, expectedSpec: nil, expectError: false, }, @@ -79,7 +79,6 @@ spec: configValues: types.AppConfigValues{ "check_name": {Value: "K8s Version Validation"}, }, - proxySpec: &ecv1beta1.ProxySpec{}, expectedSpec: &troubleshootv1beta2.PreflightSpec{ Analyzers: []*troubleshootv1beta2.Analyze{ { @@ -177,7 +176,6 @@ spec: "version_check_name": {Value: "Custom K8s Version Check"}, "resource_check_name": {Value: "Custom Node Resource Check"}, }, - proxySpec: &ecv1beta1.ProxySpec{}, expectedSpec: &troubleshootv1beta2.PreflightSpec{ Analyzers: []*troubleshootv1beta2.Analyze{ { @@ -235,7 +233,6 @@ spec: createTestChartArchive(t, "simple-chart", "1.0.0"), }, configValues: types.AppConfigValues{}, - proxySpec: &ecv1beta1.ProxySpec{}, expectedSpec: nil, expectError: false, }, @@ -334,11 +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), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -375,7 +378,6 @@ func TestAppReleaseManager_templateHelmChartCRs(t *testing.T) { name: "empty helm chart CRs", helmChartCRs: [][]byte{}, configValues: types.AppConfigValues{}, - proxySpec: &ecv1beta1.ProxySpec{}, registrySettings: nil, expected: []*kotsv1beta2.HelmChart{}, expectError: false, @@ -416,7 +418,6 @@ spec: "enable_persistence": {Value: "true"}, "disable_monitoring": {Value: "false"}, }, - proxySpec: &ecv1beta1.ProxySpec{}, expected: []*kotsv1beta2.HelmChart{ createHelmChartCRFromYAML(` apiVersion: kots.io/v1beta2 @@ -501,7 +502,6 @@ spec: "enable_resources": {Value: "false"}, "redis_persistence": {Value: "true"}, }, - proxySpec: &ecv1beta1.ProxySpec{}, expected: []*kotsv1beta2.HelmChart{ createHelmChartCRFromYAML(` apiVersion: kots.io/v1beta2 @@ -564,7 +564,6 @@ spec: `), }, configValues: types.AppConfigValues{}, - proxySpec: &ecv1beta1.ProxySpec{}, expected: []*kotsv1beta2.HelmChart{ createHelmChartCRFromYAML(` apiVersion: kots.io/v1beta2 @@ -584,7 +583,6 @@ spec: name: "nil helm chart CRs", helmChartCRs: nil, configValues: types.AppConfigValues{}, - proxySpec: &ecv1beta1.ProxySpec{}, expected: []*kotsv1beta2.HelmChart{}, expectError: false, }, @@ -678,7 +676,6 @@ spec: `), }, configValues: types.AppConfigValues{}, - proxySpec: &ecv1beta1.ProxySpec{}, // Empty proxy spec expected: []*kotsv1beta2.HelmChart{ createHelmChartCRFromYAML(` apiVersion: kots.io/v1beta2 @@ -732,14 +729,13 @@ spec: `), }, configValues: types.AppConfigValues{}, - proxySpec: &ecv1beta1.ProxySpec{}, registrySettings: &types.RegistrySettings{ - HasLocalRegistry: true, - Host: "10.128.0.11:5000", - Address: "10.128.0.11:5000/myapp", - Namespace: "myapp", - ImagePullSecretName: "embedded-cluster-registry", - ImagePullSecretValue: "dGVzdC1zZWNyZXQtdmFsdWU=", + HasLocalRegistry: true, + LocalRegistryHost: "10.128.0.11:5000", + LocalRegistryAddress: "10.128.0.11:5000/myapp", + LocalRegistryNamespace: "myapp", + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: "dGVzdC1zZWNyZXQtdmFsdWU=", }, expected: []*kotsv1beta2.HelmChart{ createHelmChartCRFromYAML(` @@ -756,7 +752,7 @@ spec: image: repository: "10.128.0.11:5000/myapp/nginx" imagePullSecrets: - - name: "embedded-cluster-registry" + - name: "test-app-registry" registry: host: "10.128.0.11:5000" address: "10.128.0.11:5000/myapp" @@ -787,7 +783,6 @@ spec: `), }, configValues: types.AppConfigValues{}, - proxySpec: &ecv1beta1.ProxySpec{}, registrySettings: &types.RegistrySettings{ HasLocalRegistry: false, }, @@ -832,7 +827,6 @@ spec: `), }, configValues: types.AppConfigValues{}, - proxySpec: &ecv1beta1.ProxySpec{}, registrySettings: nil, // No registry settings provided expected: []*kotsv1beta2.HelmChart{ createHelmChartCRFromYAML(` @@ -870,6 +864,7 @@ spec: manager, err := NewAppReleaseManager( config, WithReleaseData(releaseData), + WithHelmClient(&helm.MockClient{}), ) require.NoError(t, err) @@ -1142,9 +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), + WithHelmClient(hcli), ) require.NoError(t, err) @@ -1814,152 +1816,896 @@ data: } } -// Helper function to create HelmChart from YAML string -func createHelmChartCRFromYAML(yamlStr string) *kotsv1beta2.HelmChart { - var chart kotsv1beta2.HelmChart - err := kyaml.Unmarshal([]byte(yamlStr), &chart) - if err != nil { - panic(err) - } - return &chart -} - -// createComplexChartArchive creates a Helm chart archive with CRDs, deployment, and service -func createComplexChartArchive(t *testing.T, name, version string) []byte { - chartYaml := fmt.Sprintf(`apiVersion: v2 -name: %s -version: %s -description: A complex test Helm chart -type: application -`, name, version) - - crd := `apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition +func TestAppReleaseManager_ExtractInstallableHelmCharts(t *testing.T) { + tests := []struct { + name string + helmChartCRs [][]byte + chartArchives [][]byte + configValues types.AppConfigValues + proxySpec *ecv1beta1.ProxySpec + registrySettings *types.RegistrySettings + expectError bool + errorContains string + expected []types.InstallableHelmChart + }{ + { + name: "no helm charts returns empty slice", + helmChartCRs: [][]byte{}, + chartArchives: [][]byte{}, + configValues: types.AppConfigValues{}, + expectError: false, + expected: nil, + }, + { + name: "single chart with basic configuration", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart metadata: - name: widgets.example.com - namespace: {{ .Release.Namespace }} + name: nginx-chart spec: - group: example.com - versions: - - name: v1 - served: true - storage: true - schema: - openAPIV3Schema: - type: object - scope: Namespaced - names: - plural: widgets - singular: widget - kind: Widget -` - - deployment := `apiVersion: apps/v1 -kind: Deployment + namespace: repl{{ConfigOption "namespace"}} + releaseName: repl{{ConfigOption "release_name"}} + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "3" + image: + repository: nginx + tag: '{{repl ConfigOption "image_tag"}}' + service: + type: ClusterIP + port: 80 + optionalValues: + - when: '{{repl ConfigOptionEquals "enable_ingress" "true"}}' + values: + ingress: + enabled: true + host: '{{repl ConfigOption "ingress_host"}}'`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{ + "namespace": {Value: "custom-namespace"}, + "release_name": {Value: "custom-release-name"}, + "image_tag": {Value: "1.20.0"}, + "enable_ingress": {Value: "true"}, + "ingress_host": {Value: "nginx.example.com"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "replicaCount": "3", + "image": map[string]any{ + "repository": "nginx", + "tag": "1.20.0", + }, + "service": map[string]any{ + "type": "ClusterIP", + "port": float64(80), + }, + "ingress": map[string]any{ + "enabled": true, + "host": "nginx.example.com", + }, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart metadata: - name: {{ include "chart.fullname" . }} - namespace: {{ .Release.Namespace }} - labels: - app: {{ .Chart.Name }} + name: nginx-chart spec: - replicas: {{ .Values.replicaCount | default 1 }} - selector: - matchLabels: - app: {{ .Chart.Name }} - template: - metadata: - labels: - app: {{ .Chart.Name }} - spec: - containers: - - name: {{ .Chart.Name }} - image: {{ .Values.image.repository | default "nginx" }}:{{ .Values.image.tag | default "latest" }} - ports: - - containerPort: 80 -` - - service := `apiVersion: v1 -kind: Service + namespace: custom-namespace + releaseName: custom-release-name + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "3" + image: + repository: nginx + tag: "1.20.0" + service: + type: ClusterIP + port: 80 + optionalValues: + - when: "true" + values: + ingress: + enabled: true + host: "nginx.example.com"`), + }, + }, + }, + { + name: "chart with exclude=true should be skipped", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart metadata: - name: {{ include "chart.fullname" . }} - namespace: {{ .Release.Namespace }} - labels: - app: {{ .Chart.Name }} + name: excluded-chart spec: - type: {{ .Values.service.type | default "ClusterIP" }} - ports: - - port: 80 - targetPort: 80 - selector: - app: {{ .Chart.Name }} -` - - helpers := `{{- define "chart.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -` - - valuesYaml := `replicaCount: 1 - -image: - repository: nginx - tag: latest - pullPolicy: IfNotPresent - -service: - type: ClusterIP - port: 80 - -resources: {} -nodeSelector: {} -tolerations: [] -affinity: {} -` - - files := map[string]string{ - fmt.Sprintf("%s/Chart.yaml", name): chartYaml, - fmt.Sprintf("%s/values.yaml", name): valuesYaml, - fmt.Sprintf("%s/templates/crd.yaml", name): crd, - fmt.Sprintf("%s/templates/deployment.yaml", name): deployment, - fmt.Sprintf("%s/templates/service.yaml", name): service, - fmt.Sprintf("%s/templates/_helpers.tpl", name): helpers, - } - - return createTarGzArchive(t, files) -} - -// Helper function to create test config for template engine -func createTestConfig() kotsv1beta1.Config { - return kotsv1beta1.Config{ - Spec: kotsv1beta1.ConfigSpec{ - Groups: []kotsv1beta1.ConfigGroup{ + chart: + name: nginx + chartVersion: "1.0.0" + exclude: '{{repl ConfigOptionEquals "skip_nginx" "true"}}' + values: + replicaCount: "2"`), + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: included-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + exclude: false + values: + persistence: + enabled: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + createTestChartArchive(t, "redis", "2.0.0"), + }, + configValues: types.AppConfigValues{ + "skip_nginx": {Value: "true"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ { - Name: "test_group", - Items: []kotsv1beta1.ConfigItem{ - {Name: "chart_name", Type: "text", Value: multitype.FromString("nginx")}, - {Name: "image_tag", Type: "text", Value: multitype.FromString("1.20.0")}, - {Name: "app_name", Type: "text", Value: multitype.FromString("myapp")}, - {Name: "chart1_name", Type: "text", Value: multitype.FromString("nginx")}, - {Name: "chart1_version", Type: "text", Value: multitype.FromString("1.20.0")}, - {Name: "chart1_replicas", Type: "text", Value: multitype.FromString("3")}, - {Name: "chart2_name", Type: "text", Value: multitype.FromString("redis")}, - {Name: "service_type", Type: "text", Value: multitype.FromString("ClusterIP")}, - {Name: "service_port", Type: "text", Value: multitype.FromString("6379")}, - {Name: "enable_resources", Type: "text", Value: multitype.FromString("false")}, - {Name: "redis_persistence", Type: "text", Value: multitype.FromString("true")}, - {Name: "enable_persistence", Type: "text", Value: multitype.FromString("true")}, - {Name: "disable_monitoring", Type: "text", Value: multitype.FromString("false")}, - // Additional items for ExtractAppPreflightSpec test - {Name: "replica_count", Type: "text", Value: multitype.FromString("3")}, - {Name: "check_name", Type: "text", Value: multitype.FromString("K8s Version Validation")}, - {Name: "chart1_enabled", Type: "text", Value: multitype.FromString("true")}, - {Name: "node_count", Type: "text", Value: multitype.FromString("3")}, - {Name: "version_check_name", Type: "text", Value: multitype.FromString("Custom K8s Version Check")}, - {Name: "resource_check_name", Type: "text", Value: multitype.FromString("Custom Node Resource Check")}, + Archive: createTestChartArchive(t, "redis", "2.0.0"), + Values: map[string]any{ + "persistence": map[string]any{ + "enabled": true, + }, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: included-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + exclude: false + values: + persistence: + enabled: true`), + }, + }, + }, + { + name: "chart with recursive merge optional values", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: merge-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + service: + type: '{{repl ConfigOption "service_type"}}' + port: 80 + replicaCount: "1" + optionalValues: + - when: '{{repl ConfigOption "enable_ssl"}}' + recursiveMerge: true + values: + service: + type: LoadBalancer + ssl: + enabled: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{ + "service_type": {Value: "ClusterIP"}, + "enable_ssl": {Value: "true"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "replicaCount": "1", + "service": map[string]any{ + "type": "LoadBalancer", // from optional values (overrode base value) + "port": float64(80), // from base values (preserved) + }, + "ssl": map[string]any{ + "enabled": true, // from optional values (added) + }, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: merge-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + service: + type: "ClusterIP" + port: 80 + replicaCount: "1" + optionalValues: + - when: "true" + recursiveMerge: true + values: + service: + type: LoadBalancer + ssl: + enabled: true`), + }, + }, + }, + { + name: "chart with direct replacement optional values", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: replace-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + values: + persistence: + enabled: '{{repl ConfigOption "enable_persistence"}}' + size: "5Gi" + optionalValues: + - when: '{{repl ConfigOption "redis_persistence"}}' + recursiveMerge: false + values: + persistence: + size: "20Gi"`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "redis", "2.0.0"), + }, + configValues: types.AppConfigValues{ + "enable_persistence": {Value: "true"}, + "redis_persistence": {Value: "true"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "redis", "2.0.0"), + Values: map[string]any{ + "persistence": map[string]any{ + "size": "20Gi", // from optional values (direct replacement) + // Note: enabled=true is GONE because entire persistence key was replaced + }, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: replace-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + values: + persistence: + enabled: "true" + size: "5Gi" + optionalValues: + - when: "true" + recursiveMerge: false + values: + persistence: + size: "20Gi"`), + }, + }, + }, + { + name: "chart with proxy template functions", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: proxy-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + proxy: + http: '{{repl HTTPProxy}}' + https: '{{repl HTTPSProxy}}' + noProxy: '{{repl NoProxy | join ","}}' + optionalValues: + - when: '{{repl if HTTPProxy}}true{{repl else}}false{{repl end}}' + values: + proxyEnabled: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + proxySpec: &ecv1beta1.ProxySpec{ + HTTPProxy: "http://proxy.example.com:8080", + HTTPSProxy: "https://proxy.example.com:8443", + NoProxy: "localhost,127.0.0.1,.cluster.local", + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "proxy": map[string]any{ + "http": "http://proxy.example.com:8080", + "https": "https://proxy.example.com:8443", + "noProxy": "localhost,127.0.0.1,.cluster.local", + }, + "proxyEnabled": true, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: proxy-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + proxy: + http: "http://proxy.example.com:8080" + https: "https://proxy.example.com:8443" + noProxy: "localhost,127.0.0.1,.cluster.local" + optionalValues: + - when: "true" + values: + proxyEnabled: true`), + }, + }, + }, + { + name: "chart archive not found", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: missing-chart +spec: + chart: + name: nonexistent + chartVersion: "1.0.0" + values: + replicaCount: "1"`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), // Different chart + }, + configValues: types.AppConfigValues{}, + expectError: true, + errorContains: "find chart archive for missing-chart", + }, + { + name: "invalid when condition in optional values", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: invalid-when-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "1" + optionalValues: + - when: "not-a-boolean-value" + values: + debug: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + expectError: true, + errorContains: "generate helm values for chart invalid-when-chart", + }, + { + name: "chart with mixed when conditions", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: mixed-conditions-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "1" + optionalValues: + - when: '{{repl ConfigOption "enable_persistence"}}' + values: + persistence: + enabled: true + - when: '{{repl ConfigOption "disable_monitoring"}}' + values: + monitoring: + enabled: false`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{ + "enable_persistence": {Value: "true"}, + "disable_monitoring": {Value: "false"}, + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "replicaCount": "1", // from base values + "persistence": map[string]any{ + "enabled": true, // from optional values (when=true) + }, + // monitoring should NOT be present (when condition evaluated to false) + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: mixed-conditions-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "1" + optionalValues: + - when: "true" + values: + persistence: + enabled: true + - when: "false" + values: + monitoring: + enabled: false`), + }, + }, + }, + { + name: "nil helm chart CRs", + helmChartCRs: nil, + chartArchives: [][]byte{}, + configValues: types.AppConfigValues{}, + expectError: false, + expected: nil, + }, + { + name: "skip nil helm chart CR in collection", + helmChartCRs: [][]byte{ + nil, + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: valid-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "2"`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "replicaCount": "2", + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: valid-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + replicaCount: "2"`), + }, + }, + }, + { + name: "chart with registry template functions - airgap mode", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: registry-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + image: + repository: '{{repl HasLocalRegistry | ternary LocalRegistryHost "proxy.replicated.com"}}/{{repl HasLocalRegistry | ternary LocalRegistryNamespace "external/path"}}/nginx' + tag: "1.20.0" + imagePullSecrets: + - name: '{{repl ImagePullSecretName}}' + registry: + host: '{{repl LocalRegistryHost}}' + address: '{{repl LocalRegistryAddress}}' + namespace: '{{repl LocalRegistryNamespace}}' + secret: '{{repl LocalRegistryImagePullSecret}}' + optionalValues: + - when: '{{repl HasLocalRegistry}}' + values: + airgapMode: true`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + registrySettings: &types.RegistrySettings{ + HasLocalRegistry: true, + LocalRegistryHost: "10.128.0.11:5000", + LocalRegistryAddress: "10.128.0.11:5000/myapp", + LocalRegistryNamespace: "myapp", + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: "dGVzdC1zZWNyZXQtdmFsdWU=", + }, + expectError: false, + expected: []types.InstallableHelmChart{ + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "image": map[string]any{ + "repository": "10.128.0.11:5000/myapp/nginx", + "tag": "1.20.0", + }, + "imagePullSecrets": []any{ + map[string]any{"name": "test-app-registry"}, + }, + "registry": map[string]any{ + "host": "10.128.0.11:5000", + "address": "10.128.0.11:5000/myapp", + "namespace": "myapp", + "secret": "dGVzdC1zZWNyZXQtdmFsdWU=", + }, + "airgapMode": true, + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: registry-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + values: + image: + repository: "10.128.0.11:5000/myapp/nginx" + tag: "1.20.0" + imagePullSecrets: + - name: "test-app-registry" + registry: + host: "10.128.0.11:5000" + address: "10.128.0.11:5000/myapp" + namespace: "myapp" + secret: "dGVzdC1zZWNyZXQtdmFsdWU=" + optionalValues: + - when: "true" + values: + airgapMode: true`), + }, + }, + }, + { + name: "charts sorted by weight - negative, zero, positive", + helmChartCRs: [][]byte{ + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: positive-weight-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + weight: 100 + values: + name: "positive"`), + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: no-weight-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + values: + name: "zero"`), // No weight specified, defaults to 0 + []byte(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: negative-weight-chart +spec: + chart: + name: postgresql + chartVersion: "1.0.0" + weight: -10 + values: + name: "negative"`), + }, + chartArchives: [][]byte{ + createTestChartArchive(t, "nginx", "1.0.0"), + createTestChartArchive(t, "redis", "2.0.0"), + createTestChartArchive(t, "postgresql", "1.0.0"), + }, + configValues: types.AppConfigValues{}, + expectError: false, + expected: []types.InstallableHelmChart{ + // Should be sorted by weight: postgresql (-10), redis (0), nginx (100) + { + Archive: createTestChartArchive(t, "postgresql", "1.0.0"), + Values: map[string]any{ + "name": "negative", + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: negative-weight-chart +spec: + chart: + name: postgresql + chartVersion: "1.0.0" + weight: -10 + values: + name: "negative"`), + }, + { + Archive: createTestChartArchive(t, "redis", "2.0.0"), + Values: map[string]any{ + "name": "zero", + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: no-weight-chart +spec: + chart: + name: redis + chartVersion: "2.0.0" + values: + name: "zero"`), + }, + { + Archive: createTestChartArchive(t, "nginx", "1.0.0"), + Values: map[string]any{ + "name": "positive", + }, + CR: createHelmChartCRFromYAML(`apiVersion: kots.io/v1beta2 +kind: HelmChart +metadata: + name: positive-weight-chart +spec: + chart: + name: nginx + chartVersion: "1.0.0" + weight: 100 + values: + name: "positive"`), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create release data + releaseData := &release.ReleaseData{ + HelmChartCRs: tt.helmChartCRs, + HelmChartArchives: tt.chartArchives, + } + + // Create manager + config := createTestConfig() + manager, err := NewAppReleaseManager( + config, + WithReleaseData(releaseData), + WithHelmClient(&helm.MockClient{}), + ) + require.NoError(t, err) + + // Execute the function + result, err := manager.ExtractInstallableHelmCharts(context.Background(), tt.configValues, tt.proxySpec, tt.registrySettings) + + // Check error expectation + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + assert.Nil(t, result) + return + } + + require.NoError(t, err) + + // Validate expected results + assert.Equal(t, tt.expected, result) + }) + } +} + +// Helper function to create HelmChart from YAML string +func createHelmChartCRFromYAML(yamlStr string) *kotsv1beta2.HelmChart { + var chart kotsv1beta2.HelmChart + err := kyaml.Unmarshal([]byte(yamlStr), &chart) + if err != nil { + panic(err) + } + return &chart +} + +// createComplexChartArchive creates a Helm chart archive with CRDs, deployment, and service +func createComplexChartArchive(t *testing.T, name, version string) []byte { + chartYaml := fmt.Sprintf(`apiVersion: v2 +name: %s +version: %s +description: A complex test Helm chart +type: application +`, name, version) + + crd := `apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.example.com + namespace: {{ .Release.Namespace }} +spec: + group: example.com + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + scope: Namespaced + names: + plural: widgets + singular: widget + kind: Widget +` + + deployment := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "chart.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Chart.Name }} +spec: + replicas: {{ .Values.replicaCount | default 1 }} + selector: + matchLabels: + app: {{ .Chart.Name }} + template: + metadata: + labels: + app: {{ .Chart.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: {{ .Values.image.repository | default "nginx" }}:{{ .Values.image.tag | default "latest" }} + ports: + - containerPort: 80 +` + + service := `apiVersion: v1 +kind: Service +metadata: + name: {{ include "chart.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Chart.Name }} +spec: + type: {{ .Values.service.type | default "ClusterIP" }} + ports: + - port: 80 + targetPort: 80 + selector: + app: {{ .Chart.Name }} +` + + helpers := `{{- define "chart.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +` + + valuesYaml := `replicaCount: 1 + +image: + repository: nginx + tag: latest + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 80 + +resources: {} +nodeSelector: {} +tolerations: [] +affinity: {} +` + + files := map[string]string{ + fmt.Sprintf("%s/Chart.yaml", name): chartYaml, + fmt.Sprintf("%s/values.yaml", name): valuesYaml, + fmt.Sprintf("%s/templates/crd.yaml", name): crd, + fmt.Sprintf("%s/templates/deployment.yaml", name): deployment, + fmt.Sprintf("%s/templates/service.yaml", name): service, + fmt.Sprintf("%s/templates/_helpers.tpl", name): helpers, + } + + return createTarGzArchive(t, files) +} + +// Helper function to create test config for template engine +func createTestConfig() kotsv1beta1.Config { + return kotsv1beta1.Config{ + Spec: kotsv1beta1.ConfigSpec{ + Groups: []kotsv1beta1.ConfigGroup{ + { + Name: "test_group", + Items: []kotsv1beta1.ConfigItem{ + {Name: "chart_name", Type: "text", Value: multitype.FromString("nginx")}, + {Name: "namespace", Type: "text", Value: multitype.FromString("default-namespace")}, + {Name: "release_name", Type: "text", Value: multitype.FromString("default-release-name")}, + {Name: "image_tag", Type: "text", Value: multitype.FromString("1.20.0")}, + {Name: "app_name", Type: "text", Value: multitype.FromString("myapp")}, + {Name: "chart1_name", Type: "text", Value: multitype.FromString("nginx")}, + {Name: "chart1_version", Type: "text", Value: multitype.FromString("1.20.0")}, + {Name: "chart1_replicas", Type: "text", Value: multitype.FromString("3")}, + {Name: "chart2_name", Type: "text", Value: multitype.FromString("redis")}, + {Name: "service_type", Type: "text", Value: multitype.FromString("ClusterIP")}, + {Name: "service_port", Type: "text", Value: multitype.FromString("6379")}, + {Name: "enable_resources", Type: "text", Value: multitype.FromString("false")}, + {Name: "redis_persistence", Type: "text", Value: multitype.FromString("true")}, + {Name: "enable_persistence", Type: "text", Value: multitype.FromString("true")}, + {Name: "disable_monitoring", Type: "text", Value: multitype.FromString("false")}, + // Additional items for ExtractAppPreflightSpec test + {Name: "replica_count", Type: "text", Value: multitype.FromString("3")}, + {Name: "check_name", Type: "text", Value: multitype.FromString("K8s Version Validation")}, + {Name: "chart1_enabled", Type: "text", Value: multitype.FromString("true")}, + {Name: "node_count", Type: "text", Value: multitype.FromString("3")}, + {Name: "version_check_name", Type: "text", Value: multitype.FromString("Custom K8s Version Check")}, + {Name: "resource_check_name", Type: "text", Value: multitype.FromString("Custom Node Resource Check")}, + // Additional items for ExtractInstallableHelmCharts test + {Name: "image_tag", Type: "text", Value: multitype.FromString("1.20.0")}, + {Name: "enable_ingress", Type: "text", Value: multitype.FromString("true")}, + {Name: "ingress_host", Type: "text", Value: multitype.FromString("nginx.example.com")}, + {Name: "skip_nginx", Type: "text", Value: multitype.FromString("true")}, + {Name: "frontend_replicas", Type: "text", Value: multitype.FromString("3")}, + {Name: "frontend_tag", Type: "text", Value: multitype.FromString("1.20.0")}, + {Name: "enable_ssl", Type: "text", Value: multitype.FromString("true")}, + {Name: "redis_persistence", Type: "text", Value: multitype.FromString("true")}, + {Name: "invalid_boolean", Type: "text", Value: multitype.FromString("not-a-boolean")}, }, }, }, diff --git a/api/internal/managers/app/release/util.go b/api/internal/managers/app/release/util.go index 4f2c282a65..e4f74e0229 100644 --- a/api/internal/managers/app/release/util.go +++ b/api/internal/managers/app/release/util.go @@ -62,7 +62,7 @@ func writeChartArchiveToTemp(chartArchive []byte) (string, error) { // Write the chart archive to the temporary file if _, err := tmpFile.Write(chartArchive); err != nil { - os.Remove(tmpFile.Name()) + _ = os.Remove(tmpFile.Name()) return "", fmt.Errorf("write chart archive: %w", err) } diff --git a/api/internal/managers/kubernetes/infra/install.go b/api/internal/managers/kubernetes/infra/install.go index 07ff4ed7b8..0acf583774 100644 --- a/api/internal/managers/kubernetes/infra/install.go +++ b/api/internal/managers/kubernetes/infra/install.go @@ -157,6 +157,7 @@ func (m *infraManager) getAddonInstallOpts(license *kotsv1beta1.License, ki kube EmbeddedConfigSpec: m.getECConfigSpec(), EndUserConfigSpec: m.getEndUserConfigSpec(), ProxySpec: ki.ProxySpec(), + IsV3: true, } // TODO: no kots app install for now 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 74cd811932..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) } @@ -298,6 +298,7 @@ func (m *infraManager) getAddonInstallOpts(ctx context.Context, license *kotsv1b K0sDataDir: rc.EmbeddedClusterK0sSubDir(), OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), ServiceCIDR: rc.ServiceCIDR(), + IsV3: true, } return opts diff --git a/api/internal/managers/linux/infra/util.go b/api/internal/managers/linux/infra/util.go index 5b433d3e1f..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, - K0sVersion: 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/internal/managers/linux/installation/config.go b/api/internal/managers/linux/installation/config.go index 0c8e4f6d09..cdb9787e25 100644 --- a/api/internal/managers/linux/installation/config.go +++ b/api/internal/managers/linux/installation/config.go @@ -7,6 +7,7 @@ import ( "fmt" "runtime/debug" + "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/types" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" @@ -14,6 +15,8 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/addons/registry" "github.com/replicatedhq/embedded-cluster/pkg/netutils" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kyaml "sigs.k8s.io/yaml" ) func (m *installationManager) GetConfig() (types.LinuxInstallationConfig, error) { @@ -236,11 +239,37 @@ func (m *installationManager) ConfigureHost(ctx context.Context, rc runtimeconfi return nil } -// CalculateRegistrySettings calculates registry settings for airgap installations +// CalculateRegistrySettings calculates registry settings for both online and airgap installations func (m *installationManager) CalculateRegistrySettings(ctx context.Context, rc runtimeconfig.RuntimeConfig) (*types.RegistrySettings, error) { - // Only return registry settings for airgap installations if m.airgapBundle == "" { - return nil, nil + // Online mode: Use replicated proxy registry with license ID authentication + ecDomains := utils.GetDomains(m.releaseData) + + // Parse license from bytes + if len(m.license) == 0 { + return nil, fmt.Errorf("license is required for online registry settings") + } + license := &kotsv1beta1.License{} + if err := kyaml.Unmarshal(m.license, license); err != nil { + return nil, fmt.Errorf("parse license: %w", err) + } + + // Get app slug for secret name + if m.releaseData == nil || m.releaseData.ChannelRelease == nil || m.releaseData.ChannelRelease.AppSlug == "" { + return nil, fmt.Errorf("release data with app slug is required for registry settings") + } + appSlug := m.releaseData.ChannelRelease.AppSlug + + // Create auth config for both proxy and registry domains + authConfig := fmt.Sprintf(`{"auths":{"%s":{"username": "LICENSE_ID", "password": "%s"},"%s":{"username": "LICENSE_ID", "password": "%s"}}}`, + ecDomains.ProxyRegistryDomain, license.Spec.LicenseID, ecDomains.ReplicatedRegistryDomain, license.Spec.LicenseID) + imagePullSecretValue := base64.StdEncoding.EncodeToString([]byte(authConfig)) + + return &types.RegistrySettings{ + HasLocalRegistry: false, + ImagePullSecretName: fmt.Sprintf("%s-registry", appSlug), + ImagePullSecretValue: imagePullSecretValue, + }, nil } // Use runtime config as the authoritative source for service CIDR @@ -254,27 +283,27 @@ func (m *installationManager) CalculateRegistrySettings(ctx context.Context, rc // Construct registry host with port registryHost := fmt.Sprintf("%s:5000", registryIP) - // Get app namespace from release data - required for app preflights + // Get app slug for secret name if m.releaseData == nil || m.releaseData.ChannelRelease == nil || m.releaseData.ChannelRelease.AppSlug == "" { return nil, fmt.Errorf("release data with app slug is required for registry settings") } - appNamespace := m.releaseData.ChannelRelease.AppSlug + appSlug := m.releaseData.ChannelRelease.AppSlug // Construct full registry address with namespace + appNamespace := appSlug // registry namespace is the same as the app slug in linux target registryAddress := fmt.Sprintf("%s/%s", registryHost, appNamespace) // Create image pull secret value using the same pattern as admin console - authString := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("embedded-cluster:%s", registry.GetRegistryPassword()))) - authConfig := fmt.Sprintf(`{"auths":{"%s":{"username": "embedded-cluster", "password": "%s", "auth": "%s"}}}`, - registryHost, registry.GetRegistryPassword(), authString) + authConfig := fmt.Sprintf(`{"auths":{"%s":{"username": "embedded-cluster", "password": "%s"}}}`, + registryHost, registry.GetRegistryPassword()) imagePullSecretValue := base64.StdEncoding.EncodeToString([]byte(authConfig)) return &types.RegistrySettings{ - HasLocalRegistry: true, - Host: registryHost, - Address: registryAddress, - Namespace: appNamespace, - ImagePullSecretName: "embedded-cluster-registry", - ImagePullSecretValue: imagePullSecretValue, + HasLocalRegistry: true, + LocalRegistryHost: registryHost, + LocalRegistryAddress: registryAddress, + LocalRegistryNamespace: appNamespace, + ImagePullSecretName: fmt.Sprintf("%s-registry", appSlug), + ImagePullSecretValue: imagePullSecretValue, }, nil } diff --git a/api/internal/managers/linux/installation/config_test.go b/api/internal/managers/linux/installation/config_test.go index 7babb46d71..59513adfaa 100644 --- a/api/internal/managers/linux/installation/config_test.go +++ b/api/internal/managers/linux/installation/config_test.go @@ -2,7 +2,9 @@ package installation import ( "context" + "encoding/base64" "errors" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -13,7 +15,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/hostutils" + "github.com/replicatedhq/embedded-cluster/pkg/addons/registry" + "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kyaml "sigs.k8s.io/yaml" ) func TestValidateConfig(t *testing.T) { @@ -466,3 +472,172 @@ func TestConfigureHost(t *testing.T) { }) } } + +func TestCalculateRegistrySettings(t *testing.T) { + // Helper to create a test license + createTestLicense := func(licenseID, appSlug string) []byte { + license := kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + LicenseID: licenseID, + AppSlug: appSlug, + }, + } + licenseBytes, _ := kyaml.Marshal(license) + return licenseBytes + } + + // Helper to create test release data + createTestReleaseData := func(appSlug string, domains *ecv1beta1.Domains) *release.ReleaseData { + releaseData := &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + AppSlug: appSlug, + }, + } + if domains != nil { + releaseData.EmbeddedClusterConfig = &ecv1beta1.Config{ + Spec: ecv1beta1.ConfigSpec{ + Domains: *domains, + }, + } + } + return releaseData + } + + // Helper to create runtime config + createTestRuntimeConfig := func() runtimeconfig.RuntimeConfig { + return runtimeconfig.New(&ecv1beta1.RuntimeConfigSpec{ + Network: ecv1beta1.NetworkSpec{ + ServiceCIDR: "10.96.0.0/12", + }, + }) + } + + tests := []struct { + name string + license []byte + releaseData *release.ReleaseData + airgapBundle string + expectedResult *types.RegistrySettings + expectedError string + }{ + { + name: "online mode with default domains", + license: createTestLicense("test-license-123", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "", // Online mode + expectedResult: &types.RegistrySettings{ + HasLocalRegistry: false, + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(`{"auths":{"proxy.replicated.com":{"username": "LICENSE_ID", "password": "test-license-123"},"registry.replicated.com":{"username": "LICENSE_ID", "password": "test-license-123"}}}`)), + }, + }, + { + name: "online mode with custom domains", + license: createTestLicense("custom-license-456", "custom-app"), + releaseData: createTestReleaseData("custom-app", &ecv1beta1.Domains{ + ProxyRegistryDomain: "custom-proxy.example.com", + ReplicatedRegistryDomain: "custom-registry.example.com", + }), + airgapBundle: "", // Online mode + expectedResult: &types.RegistrySettings{ + HasLocalRegistry: false, + ImagePullSecretName: "custom-app-registry", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(`{"auths":{"custom-proxy.example.com":{"username": "LICENSE_ID", "password": "custom-license-456"},"custom-registry.example.com":{"username": "LICENSE_ID", "password": "custom-license-456"}}}`)), + }, + }, + { + name: "online mode missing license", + license: nil, + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "", // Online mode + expectedError: "license is required for online registry settings", + }, + { + name: "online mode empty license", + license: []byte{}, + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "", // Online mode + expectedError: "license is required for online registry settings", + }, + { + name: "online mode invalid license format", + license: []byte("invalid yaml"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "", // Online mode + expectedError: "parse license:", + }, + { + name: "online mode missing release data", + license: createTestLicense("test-license", "test-app"), + releaseData: nil, + airgapBundle: "", // Online mode + expectedError: "release data with app slug is required for registry settings", + }, + { + name: "online mode missing app slug", + license: createTestLicense("test-license", "test-app"), + releaseData: &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + AppSlug: "", // Empty app slug + }, + }, + airgapBundle: "", // Online mode + expectedError: "release data with app slug is required for registry settings", + }, + { + name: "airgap mode", + license: createTestLicense("test-license", "test-app"), + releaseData: createTestReleaseData("test-app", nil), + airgapBundle: "test-bundle.tar", + expectedResult: &types.RegistrySettings{ + HasLocalRegistry: true, + LocalRegistryHost: "10.96.0.11:5000", + LocalRegistryAddress: "10.96.0.11:5000/test-app", + LocalRegistryNamespace: "test-app", + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"auths":{"10.96.0.11:5000":{"username": "embedded-cluster", "password": "%s"}}}`, registry.GetRegistryPassword()))), + }, + }, + { + name: "airgap mode missing release data", + license: createTestLicense("test-license", "test-app"), + releaseData: nil, + airgapBundle: "test-bundle.tar", + expectedError: "release data with app slug is required for registry settings", + }, + { + name: "airgap mode missing app slug", + license: createTestLicense("test-license", "test-app"), + releaseData: &release.ReleaseData{ + ChannelRelease: &release.ChannelRelease{ + AppSlug: "", // Empty app slug + }, + }, + airgapBundle: "test-bundle.tar", + expectedError: "release data with app slug is required for registry settings", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rc := createTestRuntimeConfig() + + manager := NewInstallationManager( + WithLicense(tt.license), + WithReleaseData(tt.releaseData), + WithAirgapBundle(tt.airgapBundle), + ) + + result, err := manager.CalculateRegistrySettings(context.Background(), rc) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + } + }) + } +} diff --git a/api/internal/store/app/install/store.go b/api/internal/store/app/install/store.go index e807bb97a9..17be428050 100644 --- a/api/internal/store/app/install/store.go +++ b/api/internal/store/app/install/store.go @@ -3,6 +3,7 @@ package install import ( "fmt" "sync" + "time" "github.com/replicatedhq/embedded-cluster/api/types" "github.com/tiendc/go-deepcopy" @@ -20,6 +21,8 @@ type Store interface { SetStatusDesc(desc string) error AddLogs(logs string) error GetLogs() (string, error) + SetComponentStatus(componentName string, status types.Status) error + RegisterComponents(componentNames []string) error } // memoryStore is an in-memory implementation of Store @@ -107,3 +110,42 @@ func (s *memoryStore) GetLogs() (string, error) { defer s.mu.RUnlock() return s.appInstall.Logs, nil } + +// SetComponentStatus sets the status of a specific component +func (s *memoryStore) SetComponentStatus(componentName string, status types.Status) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Find and update the component + for i := range s.appInstall.Components { + if s.appInstall.Components[i].Name == componentName { + s.appInstall.Components[i].Status = status + return nil + } + } + + return fmt.Errorf("component %s not found", componentName) +} + +// RegisterComponents initializes the components list with the given component names +func (s *memoryStore) RegisterComponents(componentNames []string) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Clear existing components + s.appInstall.Components = make([]types.AppComponent, 0, len(componentNames)) + + // Initialize each component with pending status + for _, name := range componentNames { + s.appInstall.Components = append(s.appInstall.Components, types.AppComponent{ + Name: name, + Status: types.Status{ + State: types.StatePending, + Description: "", + LastUpdated: time.Now(), + }, + }) + } + + return nil +} diff --git a/api/internal/store/app/install/store_mock.go b/api/internal/store/app/install/store_mock.go index 689373fda1..06557cc1d2 100644 --- a/api/internal/store/app/install/store_mock.go +++ b/api/internal/store/app/install/store_mock.go @@ -53,3 +53,15 @@ func (m *MockStore) GetLogs() (string, error) { args := m.Called() return args.Get(0).(string), args.Error(1) } + +// SetComponentStatus mocks the SetComponentStatus method +func (m *MockStore) SetComponentStatus(componentName string, status types.Status) error { + args := m.Called(componentName, status) + return args.Error(0) +} + +// RegisterComponents mocks the RegisterComponents method +func (m *MockStore) RegisterComponents(componentNames []string) error { + args := m.Called(componentNames) + return args.Error(0) +} diff --git a/api/internal/store/app/install/store_test.go b/api/internal/store/app/install/store_test.go index e83aae151a..9492b474ac 100644 --- a/api/internal/store/app/install/store_test.go +++ b/api/internal/store/app/install/store_test.go @@ -280,3 +280,105 @@ func TestMemoryStore_DeepCopy(t *testing.T) { assert.Equal(t, "Original description", appInstall3.Status.Description) assert.Equal(t, "Original log\n", appInstall3.Logs) } + +func TestMemoryStore_RegisterComponents(t *testing.T) { + store := newMemoryStore() + + // Test registering components + componentNames := []string{"chart1", "chart2", "chart3"} + err := store.RegisterComponents(componentNames) + require.NoError(t, err) + + // Verify components were registered + appInstall, err := store.Get() + require.NoError(t, err) + require.Len(t, appInstall.Components, 3) + + for i, component := range appInstall.Components { + assert.Equal(t, componentNames[i], component.Name) + assert.Equal(t, types.StatePending, component.Status.State) + } +} + +func TestMemoryStore_SetComponentStatus(t *testing.T) { + store := newMemoryStore() + + // Register components first + componentNames := []string{"chart1", "chart2"} + err := store.RegisterComponents(componentNames) + require.NoError(t, err) + + // Test setting component status + status := types.Status{ + State: types.StateRunning, + Description: "Installing chart1", + LastUpdated: time.Now(), + } + err = store.SetComponentStatus("chart1", status) + require.NoError(t, err) + + // Verify component status was updated + appInstall, err := store.Get() + require.NoError(t, err) + assert.Equal(t, types.StateRunning, appInstall.Components[0].Status.State) + assert.Equal(t, "Installing chart1", appInstall.Components[0].Status.Description) + // Second component should remain unchanged + assert.Equal(t, types.StatePending, appInstall.Components[1].Status.State) +} + +func TestMemoryStore_SetComponentStatus_NonExistentComponent(t *testing.T) { + store := newMemoryStore() + + // Try to set status on non-existent component + status := types.Status{ + State: types.StateRunning, + Description: "Installing", + LastUpdated: time.Now(), + } + err := store.SetComponentStatus("nonexistent", status) + assert.Error(t, err) + assert.Contains(t, err.Error(), "component nonexistent not found") +} + +func TestMemoryStore_ComponentStatusConcurrency(t *testing.T) { + store := newMemoryStore() + + // Register components + componentNames := []string{"chart1", "chart2", "chart3"} + err := store.RegisterComponents(componentNames) + require.NoError(t, err) + + var wg sync.WaitGroup + numGoroutines := 10 + numOperations := 20 + + // Concurrent component status operations + wg.Add(numGoroutines * 2) + for i := 0; i < numGoroutines; i++ { + // Concurrent component status writes + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + componentName := componentNames[j%len(componentNames)] + status := types.Status{ + State: types.StateRunning, + Description: "Concurrent update", + LastUpdated: time.Now(), + } + err := store.SetComponentStatus(componentName, status) + assert.NoError(t, err) + } + }(i) + + // Concurrent reads + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + _, err := store.Get() + assert.NoError(t, err) + } + }(i) + } + + wg.Wait() +} diff --git a/api/pkg/template/registry.go b/api/pkg/template/registry.go index 8e1dce22c0..1c52ccafb5 100644 --- a/api/pkg/template/registry.go +++ b/api/pkg/template/registry.go @@ -15,7 +15,7 @@ func (e *Engine) localRegistryHost() string { if e.registrySettings == nil { return "" } - return e.registrySettings.Host + return e.registrySettings.LocalRegistryHost } // localRegistryAddress returns full registry address with namespace (e.g., "10.128.0.11:5000/myapp") @@ -23,7 +23,7 @@ func (e *Engine) localRegistryAddress() string { if e.registrySettings == nil { return "" } - return e.registrySettings.Address + return e.registrySettings.LocalRegistryAddress } // localRegistryNamespace returns the app-specific namespace for registry isolation @@ -31,7 +31,7 @@ func (e *Engine) localRegistryNamespace() string { if e.registrySettings == nil { return "" } - return e.registrySettings.Namespace + return e.registrySettings.LocalRegistryNamespace } // imagePullSecretName returns the standardized image pull secret name diff --git a/api/pkg/template/registry_test.go b/api/pkg/template/registry_test.go index 40799242dd..bca6c33ec5 100644 --- a/api/pkg/template/registry_test.go +++ b/api/pkg/template/registry_test.go @@ -67,14 +67,14 @@ func TestEngine_LocalRegistryHost(t *testing.T) { { name: "empty host returns empty string", registrySettings: &types.RegistrySettings{ - Host: "", + LocalRegistryHost: "", }, expectedResult: "", }, { name: "host with port returns host", registrySettings: &types.RegistrySettings{ - Host: "10.128.0.11:5000", + LocalRegistryHost: "10.128.0.11:5000", }, expectedResult: "10.128.0.11:5000", }, @@ -112,14 +112,14 @@ func TestEngine_LocalRegistryAddress(t *testing.T) { { name: "empty address returns empty string", registrySettings: &types.RegistrySettings{ - Address: "", + LocalRegistryAddress: "", }, expectedResult: "", }, { name: "address with namespace returns address", registrySettings: &types.RegistrySettings{ - Address: "10.128.0.11:5000/myapp", + LocalRegistryAddress: "10.128.0.11:5000/myapp", }, expectedResult: "10.128.0.11:5000/myapp", }, @@ -157,14 +157,14 @@ func TestEngine_LocalRegistryNamespace(t *testing.T) { { name: "empty namespace returns empty string", registrySettings: &types.RegistrySettings{ - Namespace: "", + LocalRegistryNamespace: "", }, expectedResult: "", }, { name: "namespace returns namespace", registrySettings: &types.RegistrySettings{ - Namespace: "myapp", + LocalRegistryNamespace: "myapp", }, expectedResult: "myapp", }, @@ -209,9 +209,9 @@ func TestEngine_ImagePullSecretName(t *testing.T) { { name: "secret name returns secret name", registrySettings: &types.RegistrySettings{ - ImagePullSecretName: "embedded-cluster-registry", + ImagePullSecretName: "test-app-registry", }, - expectedResult: "embedded-cluster-registry", + expectedResult: "test-app-registry", }, } @@ -281,12 +281,12 @@ func TestEngine_LocalRegistryImagePullSecret(t *testing.T) { // TestEngine_RegistryFunctionsIntegrated tests multiple registry functions in a single template func TestEngine_RegistryFunctionsIntegrated(t *testing.T) { registrySettings := &types.RegistrySettings{ - HasLocalRegistry: true, - Host: "10.128.0.11:5000", - Address: "10.128.0.11:5000/myapp", - Namespace: "myapp", - ImagePullSecretName: "embedded-cluster-registry", - ImagePullSecretValue: "eyJhdXRocyI6e319", + HasLocalRegistry: true, + LocalRegistryHost: "10.128.0.11:5000", + LocalRegistryAddress: "10.128.0.11:5000/myapp", + LocalRegistryNamespace: "myapp", + ImagePullSecretName: "test-app-registry", + ImagePullSecretValue: "eyJhdXRocyI6e319", } tests := []struct { @@ -312,7 +312,7 @@ func TestEngine_RegistryFunctionsIntegrated(t *testing.T) { { name: "image pull secret name in yaml", template: "- name: '{{repl ImagePullSecretName }}'", - expectedResult: "- name: 'embedded-cluster-registry'", + expectedResult: "- name: 'test-app-registry'", }, } diff --git a/api/types/api.go b/api/types/api.go index be6df45af0..e704cbe8e9 100644 --- a/api/types/api.go +++ b/api/types/api.go @@ -6,11 +6,16 @@ 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 ( + InstallTargetLinux InstallTarget = "linux" + InstallTargetKubernetes InstallTarget = "kubernetes" ) // APIConfig holds the configuration for the API server type APIConfig struct { + InstallTarget InstallTarget Password string TLSConfig TLSConfig License []byte @@ -26,12 +31,13 @@ type APIConfig struct { KubernetesConfig } +type InstallTarget string + type LinuxConfig struct { RuntimeConfig runtimeconfig.RuntimeConfig AllowIgnoreHostPreflights bool } type KubernetesConfig struct { - RESTClientGetter genericclioptions.RESTClientGetter - Installation kubernetesinstallation.Installation + Installation kubernetesinstallation.Installation } diff --git a/api/types/app.go b/api/types/app.go index 14ca5aef47..15d0726060 100644 --- a/api/types/app.go +++ b/api/types/app.go @@ -1,6 +1,9 @@ package types -import kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" +import ( + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" +) // AppConfig represents the configuration for an app. This is an alias for the // kotsv1beta1.ConfigSpec type. @@ -15,8 +18,24 @@ type AppConfigValue struct { // AppConfigValues represents a map of configuration values for the App. type AppConfigValues map[string]AppConfigValue -// AppInstall represents the current state of app installation +// AppInstall represents the current state of app application installation with detailed chart +// components, following the same schema pattern as types.Infra. type AppInstall struct { - Status Status `json:"status"` - Logs string `json:"logs"` + Components []AppComponent `json:"components"` + Status Status `json:"status"` + Logs string `json:"logs"` +} + +// AppComponent represents an individual chart component within the app +// Following the same schema pattern as types.InfraComponent +type AppComponent struct { + Name string `json:"name"` // Chart name + Status Status `json:"status"` // Uses existing Status type +} + +// InstallableHelmChart represents a Helm chart with pre-processed values ready for installation +type InstallableHelmChart struct { + Archive []byte + Values map[string]any + CR *kotsv1beta2.HelmChart } diff --git a/api/types/registry.go b/api/types/registry.go index db910a5dc6..c5b063419e 100644 --- a/api/types/registry.go +++ b/api/types/registry.go @@ -5,14 +5,14 @@ type RegistrySettings struct { // HasLocalRegistry indicates if a local registry is available (airgap installations) HasLocalRegistry bool `json:"hasLocalRegistry"` - // Host is the registry host with port (e.g., "10.128.0.11:5000") - Host string `json:"host"` + // LocalRegistryHost is the registry host with port (e.g., "10.128.0.11:5000") + LocalRegistryHost string `json:"host"` - // Address is the full registry address with namespace (e.g., "10.128.0.11:5000/myapp") - Address string `json:"address"` + // LocalRegistryAddress is the full registry address with namespace (e.g., "10.128.0.11:5000/myapp") + LocalRegistryAddress string `json:"address"` - // Namespace is the app-specific namespace for registry isolation - Namespace string `json:"namespace"` + // LocalRegistryNamespace is the app-specific namespace for registry isolation + LocalRegistryNamespace string `json:"namespace"` // ImagePullSecretName is the standardized image pull secret name ImagePullSecretName string `json:"imagePullSecretName"` diff --git a/cmd/buildtools/metadata.go b/cmd/buildtools/metadata.go index e3b44ef54e..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,14 +45,15 @@ var metadataExtractHelmChartImagesCommand = &cli.Command{ charts := metadata.Configs.Charts hcli, err := helm.NewClient(helm.HelmOptions{ - K0sVersion: metadata.Versions["Kubernetes"], + HelmPath: "helm", // use the helm binary in PATH + K8sVersion: metadata.Versions["Kubernetes"], }) if err != nil { return fmt.Errorf("failed to create helm client: %w", err) } 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 8830e6be53..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,8 +521,8 @@ func NewHelm() (helm.Client, error) { return nil, fmt.Errorf("get k0s version: %w", err) } return helm.NewClient(helm.HelmOptions{ - Writer: logrus.New().Writer(), - K0sVersion: sv.Original(), + 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.go b/cmd/installer/cli/api.go index a1eaf0c9de..1acd479360 100644 --- a/cmd/installer/cli/api.go +++ b/cmd/installer/cli/api.go @@ -27,8 +27,7 @@ import ( type apiOptions struct { apitypes.APIConfig - ManagerPort int - InstallTarget string + ManagerPort int Logger logrus.FieldLogger MetricsReporter metrics.ReporterInterface @@ -86,7 +85,7 @@ func serveAPI(ctx context.Context, listener net.Listener, cert tls.Certificate, webServer, err := web.New(web.InitialState{ Title: opts.ReleaseData.Application.Spec.Title, Icon: opts.ReleaseData.Application.Spec.Icon, - InstallTarget: opts.InstallTarget, + InstallTarget: string(opts.InstallTarget), }, web.WithLogger(logger), web.WithAssetsFS(opts.WebAssetsFS)) if err != nil { return fmt.Errorf("new web server: %w", err) diff --git a/cmd/installer/cli/api_test.go b/cmd/installer/cli/api_test.go index bf44acd518..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,9 +56,14 @@ 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{ - Password: "password", + InstallTarget: apitypes.InstallTargetLinux, + Password: "password", ReleaseData: &release.ReleaseData{ Application: &kotsv1beta1.Application{ Spec: kotsv1beta1.ApplicationSpec{}, @@ -67,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 6308fdcbd4..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(), - K0sVersion: 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 1a00f861d8..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 } @@ -696,7 +674,8 @@ func runManagerExperienceInstall( apiConfig := apiOptions{ APIConfig: apitypes.APIConfig{ - Password: flags.adminConsolePassword, + InstallTarget: apitypes.InstallTarget(flags.target), + Password: flags.adminConsolePassword, TLSConfig: apitypes.TLSConfig{ CertBytes: flags.tlsCertBytes, KeyBytes: flags.tlsKeyBytes, @@ -716,13 +695,11 @@ func runManagerExperienceInstall( AllowIgnoreHostPreflights: flags.ignoreHostPreflights, }, KubernetesConfig: apitypes.KubernetesConfig{ - RESTClientGetter: flags.kubernetesRESTClientGetter, - Installation: ki, + Installation: ki, }, }, ManagerPort: flags.managerPort, - InstallTarget: flags.target, MetricsReporter: metricsReporter, } @@ -802,9 +779,10 @@ func runInstall(ctx context.Context, flags InstallCmdFlags, rc runtimeconfig.Run } hcli, err := helm.NewClient(helm.HelmOptions{ - KubeConfig: rc.PathToKubeConfig(), - K0sVersion: 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 bee3462138..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(), - K0sVersion: 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 32675a8fb0..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(), - K0sVersion: 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(), - K0sVersion: 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(), - K0sVersion: 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/e2e/kots-release-install-v3/nginx-app-helm-v1beta2.yaml b/e2e/kots-release-install-v3/nginx-app-helm-v1beta2.yaml index 6037f0d2ce..91414bd02d 100644 --- a/e2e/kots-release-install-v3/nginx-app-helm-v1beta2.yaml +++ b/e2e/kots-release-install-v3/nginx-app-helm-v1beta2.yaml @@ -1,7 +1,7 @@ apiVersion: kots.io/v1beta2 kind: HelmChart metadata: - name: nginx-app + name: Nginx App spec: chart: name: nginx-app @@ -42,10 +42,10 @@ spec: configItemsConfigMapData: # Registry template functions verification has_local_registry: repl{{ HasLocalRegistry }} - local_registry_host: repl{{ HasLocalRegistry | ternary LocalRegistryHost "fallback-host" }} - local_registry_namespace: repl{{ HasLocalRegistry | ternary LocalRegistryNamespace "fallback-namespace" }} - local_registry_address: repl{{ HasLocalRegistry | ternary LocalRegistryAddress "fallback-address" }} - image_pull_secret_name: repl{{ HasLocalRegistry | ternary ImagePullSecretName "fallback-secret" }} + local_registry_host: repl{{ HasLocalRegistry | ternary LocalRegistryHost "ec-e2e-proxy.testcluster.net" }} + local_registry_namespace: repl{{ HasLocalRegistry | ternary LocalRegistryNamespace "anonymous/registry.replicated.com/library" }} + local_registry_address: repl{{ HasLocalRegistry | ternary LocalRegistryAddress "ec-e2e-proxy.testcluster.net/anonymous/registry.replicated.com/library" }} + image_pull_secret_name: repl{{ ImagePullSecretName }} image_pull_secret_value: repl{{ LocalRegistryImagePullSecret }} # Text items 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 f4a9630363..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{ - K0sVersion: versions.K0sVersion, + 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/adminconsole.go b/pkg/addons/adminconsole/adminconsole.go index 79663a6035..32da1b8765 100644 --- a/pkg/addons/adminconsole/adminconsole.go +++ b/pkg/addons/adminconsole/adminconsole.go @@ -23,6 +23,7 @@ type AdminConsole struct { IsMultiNodeEnabled bool Proxy *ecv1beta1.ProxySpec AdminConsolePort int + IsV3 bool // Linux specific options ClusterID string 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/adminconsole/values.go b/pkg/addons/adminconsole/values.go index a414d1c091..0eaec05db6 100644 --- a/pkg/addons/adminconsole/values.go +++ b/pkg/addons/adminconsole/values.go @@ -50,6 +50,7 @@ func (a *AdminConsole) GenerateHelmValues(ctx context.Context, kcli client.Clien copiedValues["isHA"] = a.IsHA copiedValues["isMultiNodeEnabled"] = a.IsMultiNodeEnabled copiedValues["isAirgap"] = a.IsAirgap + copiedValues["isEmbeddedClusterV3"] = a.IsV3 if domains.ReplicatedAppDomain != "" { copiedValues["replicatedAppEndpoint"] = netutils.MaybeAddHTTPS(domains.ReplicatedAppDomain) diff --git a/pkg/addons/adminconsole/values_test.go b/pkg/addons/adminconsole/values_test.go index b5083c7916..31bf916d0a 100644 --- a/pkg/addons/adminconsole/values_test.go +++ b/pkg/addons/adminconsole/values_test.go @@ -95,6 +95,7 @@ func TestGenerateHelmValues_Target(t *testing.T) { IsMultiNodeEnabled: false, Proxy: nil, AdminConsolePort: 8080, + IsV3: true, ClusterID: "123", ServiceCIDR: "10.0.0.0/24", @@ -106,10 +107,10 @@ func TestGenerateHelmValues_Target(t *testing.T) { values, err := adminConsole.GenerateHelmValues(context.Background(), nil, ecv1beta1.Domains{}, nil) require.NoError(t, err, "GenerateHelmValues should not return an error") - assert.Contains(t, values, "embeddedClusterID") assert.Equal(t, "123", values["embeddedClusterID"]) assert.Equal(t, dataDir, values["embeddedClusterDataDir"]) assert.Equal(t, filepath.Join(dataDir, "k0s"), values["embeddedClusterK0sDir"]) + assert.Equal(t, true, values["isEmbeddedClusterV3"]) assert.Contains(t, values["extraEnv"], map[string]interface{}{ "name": "SSL_CERT_CONFIGMAP", @@ -128,6 +129,7 @@ func TestGenerateHelmValues_Target(t *testing.T) { IsMultiNodeEnabled: false, Proxy: nil, AdminConsolePort: 8080, + IsV3: true, } values, err := adminConsole.GenerateHelmValues(context.Background(), nil, ecv1beta1.Domains{}, nil) @@ -136,6 +138,7 @@ func TestGenerateHelmValues_Target(t *testing.T) { assert.NotContains(t, values, "embeddedClusterID") assert.NotContains(t, values, "embeddedClusterDataDir") assert.NotContains(t, values, "embeddedClusterK0sDir") + assert.Equal(t, true, values["isEmbeddedClusterV3"]) for _, env := range values["extraEnv"].([]map[string]interface{}) { assert.NotEqual(t, "SSL_CERT_CONFIGMAP", env["name"], "SSL_CERT_CONFIGMAP environment variable should not be set") 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/highavailability.go b/pkg/addons/highavailability.go index 8ffd5dddaf..901a76c9a0 100644 --- a/pkg/addons/highavailability.go +++ b/pkg/addons/highavailability.go @@ -251,6 +251,7 @@ func (a *AddOns) EnableAdminConsoleHA(ctx context.Context, opts EnableHAOptions) DataDir: opts.DataDir, K0sDataDir: opts.K0sDataDir, AdminConsolePort: opts.AdminConsolePort, + IsV3: false, } if err := ac.Upgrade(ctx, a.logf, a.kcli, a.mcli, a.hcli, a.domains, a.addOnOverrides(ac, opts.EmbeddedConfigSpec, opts.EndUserConfigSpec)); err != nil { return errors.Wrap(err, "upgrade admin console") diff --git a/pkg/addons/install.go b/pkg/addons/install.go index 3f5ddcde70..598d729fcb 100644 --- a/pkg/addons/install.go +++ b/pkg/addons/install.go @@ -28,6 +28,7 @@ type InstallOptions struct { EndUserConfigSpec *ecv1beta1.ConfigSpec KotsInstaller adminconsole.KotsInstaller ProxySpec *ecv1beta1.ProxySpec + IsV3 bool // Linux only options ClusterID string @@ -52,6 +53,7 @@ type KubernetesInstallOptions struct { EndUserConfigSpec *ecv1beta1.ConfigSpec KotsInstaller adminconsole.KotsInstaller ProxySpec *ecv1beta1.ProxySpec + IsV3 bool } func (a *AddOns) Install(ctx context.Context, opts InstallOptions) error { @@ -160,6 +162,7 @@ func GetAddOnsForInstall(opts InstallOptions) []types.AddOn { DataDir: opts.DataDir, K0sDataDir: opts.K0sDataDir, AdminConsolePort: opts.AdminConsolePort, + IsV3: opts.IsV3, Password: opts.AdminConsolePwd, TLSCertBytes: opts.TLSCertBytes, @@ -195,6 +198,7 @@ func GetAddOnsForKubernetesInstall(opts KubernetesInstallOptions) []types.AddOn IsMultiNodeEnabled: opts.IsMultiNodeEnabled, Proxy: opts.ProxySpec, AdminConsolePort: opts.AdminConsolePort, + IsV3: opts.IsV3, Password: opts.AdminConsolePwd, TLSCertBytes: opts.TLSCertBytes, 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/upgrade.go b/pkg/addons/upgrade.go index 1a14dbf9ba..fa3241b54e 100644 --- a/pkg/addons/upgrade.go +++ b/pkg/addons/upgrade.go @@ -118,6 +118,7 @@ func (a *AddOns) getAddOnsForUpgrade(meta *ectypes.ReleaseMetadata, opts Upgrade DataDir: opts.DataDir, K0sDataDir: opts.K0sDataDir, AdminConsolePort: opts.AdminConsolePort, + IsV3: false, }) return addOns, nil 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 85b327016b..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,99 +11,59 @@ 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.K0sVersion != "" { - sv, err := semver.NewVersion(opts.K0sVersion) + if opts.K8sVersion != "" { + sv, err := semver.NewVersion(opts.K8sVersion) if err != nil { return nil, fmt.Errorf("parse k0s version: %w", err) } 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 - K0sVersion 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/helm_direct_install.md b/proposals/helm_direct_install.md new file mode 100644 index 0000000000..07e6195ec8 --- /dev/null +++ b/proposals/helm_direct_install.md @@ -0,0 +1,55 @@ +# Proposal: Direct Helm Chart Installation for V3 API + +**Status:** Proposed +**Epic Proposal:** [V3 App Deployment Transition](./v3_app_deployment_transition.md) +**Story:** [sc-128045](https://app.shortcut.com/replicated/story/128045) +**Iteration:** 1 (Foundation) + +## TL;DR + +Enhance the V3 API app installation manager to deploy Helm charts directly from releaseData.HelmChartArchives before calling KOTS CLI. This is the foundational story for transitioning application +deployment ownership from KOTS to the V3 embedded-cluster binary. + +## Scope + +This proposal covers only the basic Helm chart installation functionality needed for Iteration 1: +- Add Helm client to V3 API installation manager +- Install charts from releaseData.HelmChartArchives using default configuration +- Coordinate with KOTS CLI for metadata management + +**Out of scope for this story:** +- HelmChart custom resource field support (covered in Iteration 2) +- Airgap image handling (covered in Iteration 3) +- Progress reporting UI (covered in Iteration 4) + +See the [epic proposal](./v3_app_deployment_transition.md) for the complete implementation plan and architectural vision. + +## Implementation + +**Core Changes:** +- `api/internal/managers/app/install/manager.go` - Add Helm client field and constructor options +- `api/internal/managers/app/install/install.go` - Add `installHelmCharts()` method before KOTS CLI call +- `api/internal/managers/app/install/util.go` - Add Helm client setup utilities + +**Key Technical Decisions:** +1. **Basic Chart Installation Only:** Use chart name as release name, install to kotsadm namespace +2. **Sequential Processing:** Install charts one at a time for reliability +3. **No Rollback Logic:** Fail fast on errors, consistent with existing patterns + +## Testing + +- Unit tests with Helm client mocks in `install_test.go` +- Integration tests using unified releaseData structure +- Compatibility verification that non-V3 installations remain unchanged + +## Dependencies + +- Requires KOTS skip deployment changes (sc-128049) to prevent conflicts +- Foundation for HelmChart custom resource support in Iteration 2 + +The key changes: +1. Reduced scope - Focus only on the basic Helm installation for this specific story +2. Clear references - Point to the epic proposal for the bigger picture +3. Iteration context - Explicitly state this is Iteration 1 foundation work +4. Dependencies - Clear about what this depends on and enables +5. Removed duplicate content - Architecture, risk assessment, etc. are in the epic proposal \ No newline at end of file diff --git a/proposals/helm_direct_install_research.md b/proposals/helm_direct_install_research.md new file mode 100644 index 0000000000..da2ffea200 --- /dev/null +++ b/proposals/helm_direct_install_research.md @@ -0,0 +1,160 @@ +# Research: Direct Helm Chart Installation Without KOTS CLI + +## Executive Summary +This research document analyzes the current implementation and identifies key areas for modifying the app install endpoint to install Helm charts directly while maintaining KOTS CLI for version record creation. + +## Current Architecture + +### 1. Installation Flow +The current installation flow follows this sequence: +1. `/kubernetes/install/app/install` endpoint receives request +2. `InstallController.InstallApp()` validates state transitions +3. `AppInstallManager.Install()` invokes KOTS CLI +4. KOTS CLI handles entire installation including: + - Creating version records + - Installing Helm charts + - Managing application state + +### 2. Key Components + +#### AppInstallManager (`api/internal/managers/app/install/`) +- Primary interface for app installation +- Current implementation delegates entirely to KOTS CLI via `kotscli.Install()` +- Manages installation status and logging + +#### KOTS CLI Integration (`cmd/installer/kotscli/kotscli.go`) +- `Install()` function executes kubectl-kots binary +- Passes configuration via command-line arguments +- Handles both online and airgap installations + +#### Release Data (`pkg/release/release.go`) +- Contains `ReleaseData` struct with `HelmChartArchives [][]byte` +- Helm charts are already extracted and available in memory +- Also contains `HelmChartCRs [][]byte` for Helm chart custom resources + +#### Helm Client (`pkg/helm/client.go`) +- Existing robust Helm client implementation +- Supports install, upgrade, uninstall operations +- Already used throughout the codebase for addon installation + +## Technical Analysis + +### 1. Available Helm Chart Data +The `ReleaseData` structure already contains: +```go +type ReleaseData struct { + // ... other fields + HelmChartCRs [][]byte // Helm chart custom resources + HelmChartArchives [][]byte // Actual Helm chart archives +} +``` + +### 2. Existing Helm Infrastructure +The codebase has a complete Helm client implementation that: +- Supports chart installation from archives +- Handles namespace creation +- Manages releases +- Provides timeout and retry logic + +### 3. KOTS CLI Invocation Pattern +Current KOTS CLI invocation uses these key parameters: +- `--exclude-admin-console`: Already excludes admin console deployment +- `--app-version-label`: Version tracking +- `--config-values`: Application configuration +- `--skip-preflights`: Conditionally skip preflight checks + +## Implementation Considerations + +### 1. Dual Path Architecture +Need to implement: +- KOTS CLI path: Version record creation only (coordinated with sc-128049) +- Direct Helm path: Actual chart deployment + +### 2. Chart Installation Requirements +- Extract charts from `HelmChartArchives` +- Apply configuration values +- Maintain proper installation order +- Handle dependencies + +### 3. State Management +- Coordinate state transitions between KOTS and Helm operations +- Handle partial failures +- Implement rollback capabilities + +### 4. Configuration Mapping +- Transform KOTS config values to Helm values +- Handle templating requirements +- Manage secrets and sensitive data + +## Identified Challenges + +### 1. Coordination with sc-128049 +- Need to ensure KOTS CLI changes are compatible +- Timing of deployment between stories +- Feature flag or version detection strategy + +### 2. Error Handling Complexity +- Dual-path failures (KOTS success, Helm failure) +- Partial installation states +- Recovery mechanisms + +### 3. Backward Compatibility +- Support for existing installations +- Migration path for current deployments +- Feature detection and gradual rollout + +### 4. Observability +- Logging across two systems +- Metrics collection +- Debugging capabilities + +## Key Files to Modify + +### Primary Changes +1. `api/internal/managers/app/install/install.go` - Core installation logic +2. `api/internal/managers/app/install/manager.go` - Manager interface updates +3. `cmd/installer/kotscli/kotscli.go` - KOTS CLI invocation modifications + +### Supporting Changes +1. `api/internal/handlers/kubernetes/install.go` - Endpoint handlers +2. `api/controllers/app/install/install.go` - Controller logic +3. Configuration and test files + +## Dependencies and Risks + +### Dependencies +- Story sc-128049 (KOTS CLI modification) +- Existing Helm client functionality +- Release data structure + +### Risks +1. **High Risk**: Dual deployment path complexity +2. **Medium Risk**: State synchronization issues +3. **Medium Risk**: Rollback complexity +4. **Low Risk**: Performance impact + +## Recommendations + +### 1. Phased Implementation +- Phase 1: Add Helm installation capability alongside KOTS +- Phase 2: Modify KOTS CLI invocation (with sc-128049) +- Phase 3: Full integration and testing + +### 2. Feature Flag Strategy +- Implement feature flag for gradual rollout +- Allow fallback to original behavior +- Enable A/B testing in production + +### 3. Comprehensive Testing +- Unit tests for new Helm installation logic +- Integration tests for dual-path scenario +- E2E tests for complete workflow +- Rollback scenario testing + +### 4. Monitoring Strategy +- Add detailed logging at each step +- Implement metrics for success/failure rates +- Create dashboards for deployment monitoring + +## Conclusion +The architecture supports this change with existing Helm infrastructure and available chart data. The primary complexity lies in coordinating the dual deployment paths and ensuring proper state management. A phased approach with feature flags and comprehensive testing will minimize risk. \ No newline at end of file diff --git a/proposals/helmchart_values_support.md b/proposals/helmchart_values_support.md new file mode 100644 index 0000000000..8addc9a4f9 --- /dev/null +++ b/proposals/helmchart_values_support.md @@ -0,0 +1,112 @@ +# Proposal: HelmChart Values and OptionalValues Field Support + +**Status:** Proposed +**Epic Proposal:** [V3 App Deployment Transition](./v3_app_deployment_transition.md) +**Story:** [sc-128065](https://app.shortcut.com/replicated/story/128065) +**Iteration:** 2 (HelmChart Resource Implementation) + +## TL;DR + +Add a new method to the app release manager to extract templated HelmChart CRs with processed values, then pass this data to the app install manager. This follows the established API architecture pattern where the controller orchestrates data flow between managers, similar to how `ExtractAppPreflightSpec` works. + +## Scope + +This proposal covers the integration of HelmChart custom resource values processing into the V3 installation flow: +- Add `ExtractHelmCharts` method to app release manager interface +- Process HelmChart CRs and generate values using existing `generateHelmValues` function +- Pass installable Helm charts to app install manager + +**Out of scope for this story:** +- Other HelmChart CR fields (helmUpgradeFlags, weight, etc. - covered in other Iteration 2 stories) +- Template function enhancements (using existing functionality) +- Progress reporting (covered in Iteration 4) + +See the [epic proposal](./v3_app_deployment_transition.md) for the complete implementation plan and architectural vision. + +## Implementation + +Following the established API architecture where **controllers orchestrate data flow between managers without cross-manager dependencies**: + +**Core Changes:** +- `api/internal/managers/app/release/manager.go` - Add `ExtractInstallableHelmCharts` method to interface +- `api/internal/managers/app/release/template.go` - Implement extraction method using existing `generateHelmValues` +- `api/controllers/app/install/appinstall.go` - Call release manager to extract installable charts, then pass to install manager +- `api/internal/managers/app/install/install.go` - Update `Install` method to accept installable helm charts + +**Technical Approach:** + +1. **App Release Manager (Complete Data Processing):** +```go +type InstallableHelmChart struct { + Archive []byte + Values map[string]any + CR *kotsv1beta2.HelmChart +} + +type AppReleaseManager interface { + ExtractAppPreflightSpec(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec) (*troubleshootv1beta2.PreflightSpec, error) + ExtractInstallableHelmCharts(ctx context.Context, configValues types.AppConfigValues, proxySpec *ecv1beta1.ProxySpec) ([]InstallableHelmChart, error) // New method +} +``` + +2. **Controller Orchestration (similar to preflight pattern):** +```go +// In app install controller +configValues, err := c.GetAppConfigValues(ctx) +installableCharts, err := c.appReleaseManager.ExtractInstallableHelmCharts(ctx, configValues, proxySpec) +err = c.appInstallManager.Install(ctx, installableCharts) +``` + +3. **App Install Manager (Installation Only):** +```go +// Update existing Install method signature +func (m *appInstallManager) Install(ctx context.Context, installableCharts []InstallableHelmChart) error +``` + +4. **Installation with Pre-Processed Values:** +```go +func (m *appInstallManager) installHelmChart(ctx context.Context, installableChart InstallableHelmChart) error { + // Fallback to admin console namespace if namespace is not set + namespace := installableChart.CR.GetNamespace() + if namespace == "" { + namespace = constants.KotsadmNamespace + } + + // Values are already processed by release manager + _, err = m.hcli.Install(ctx, helm.InstallOptions{ + ChartPath: chartPath, + Namespace: namespace, // From HelmChart CR + ReleaseName: installableChart.CR.GetReleaseName(), // From HelmChart CR + Values: installableChart.Values, // Pre-processed from HelmChart CR + }) +} +``` + +**Key Technical Decisions:** +1. **Clean Separation of Concerns:** Release manager handles all data processing, install manager focuses on installation mechanics +2. **Single Source of Truth:** All chart data processing happens in the release manager +3. **Pre-Processed Data:** Values are processed once by the release manager, not during installation +4. **Complete Data Package:** Each InstallableHelmChart contains everything needed for installation + +## Dependencies + +- **Requires:** Direct Helm Chart Installation (sc-128045) - provides the basic installation infrastructure +- **Follows:** Same pattern as `ExtractAppPreflightSpec` - controller orchestration between release and install managers +- **Enables:** Other HelmChart CR field support stories in Iteration 2 + +## Testing + +- Unit tests for `ExtractHelmCharts` method with various HelmChart CRs +- Unit tests for updated `Install` method with templated CRs +- Integration tests validating controller orchestration between managers +- Backward compatibility tests ensuring basic installation still works when CRs are nil +- Values precedence testing (base values vs optionalValues) + +## Risk Assessment + +**Low Risk Implementation:** +- Follows established API architecture patterns (`ExtractAppPreflightSpec`) +- Leverages existing, proven `generateHelmValues` function +- Clear separation of concerns between managers via controller orchestration +- Isolated to V3 API installations only +- Graceful fallback when HelmChart CRs are not present \ No newline at end of file diff --git a/proposals/helmchart_values_support_research.md b/proposals/helmchart_values_support_research.md new file mode 100644 index 0000000000..d1bf856345 --- /dev/null +++ b/proposals/helmchart_values_support_research.md @@ -0,0 +1,145 @@ +# Research: HelmChart Values and OptionalValues Support + +## Executive Summary +This research document analyzes the existing `generateHelmValues` function and identifies the integration points needed to support values and optionalValues fields from HelmChart custom resources during V3 direct Helm installation. + +## Current Implementation Analysis + +### 1. Value Processing Function +Located at `api/internal/managers/app/release/template.go:generateHelmValues()`: + +**Function Signature:** +```go +func generateHelmValues(templatedCR *kotsv1beta2.HelmChart) (map[string]any, error) +``` + +**Processing Logic:** +1. Starts with base values from `templatedCR.Spec.Values` +2. Iterates through `templatedCR.Spec.OptionalValues` +3. Evaluates "when" condition for each optionalValue +4. Merges values based on `RecursiveMerge` flag +5. Converts MappedChartValue to standard Go interfaces + +### 2. HelmChart CR Templating +Located at `api/internal/managers/app/release/template.go:templateHelmChartCRs()`: + +**Function Purpose:** +- Templates HelmChart CRs using config values +- Executes template engine with proxy spec support +- Returns templated HelmChart CR objects + +**Key Operations:** +1. Parses YAML as template +2. Executes template with config values +3. Unmarshals back to HelmChart CR struct + +### 3. Current Install Manager Gap + +**Current Implementation (`api/internal/managers/app/install/install.go`):** +```go +func (m *appInstallManager) installHelmChart(ctx context.Context, chartArchive []byte, chartIndex int) error { + // TODO: namespace should come from HelmChart custom resource + // TODO: release name should come from HelmChart custom resource + _, err = m.hcli.Install(ctx, helm.InstallOptions{ + ChartPath: chartPath, + Namespace: constants.KotsadmNamespace, + ReleaseName: ch.Metadata.Name, + // Missing: Values field not populated + }) +} +``` + +**Identified Gaps:** +- No HelmChart CR processing +- No values extraction or processing +- No template engine integration +- Config values not available in install manager + +## Integration Requirements + +### 1. Data Flow +``` +Config Values → Template Engine → HelmChart CR → generateHelmValues() → Helm Install +``` + +### 2. Required Components + +**Template Engine:** +- Already exists in release manager +- Needs to be added to install manager +- Used for processing HelmChart CRs with config values + +**Config Values:** +- Currently passed to KOTS CLI +- Need to be available in install manager +- Required for templating HelmChart CRs + +**HelmChart CR Access:** +- Available in `m.releaseData.HelmChartCRs` +- Need correlation with chart archives by index +- Must handle missing CRs gracefully + +### 3. Value Merging Logic + +**Base Values:** +- Direct key-value pairs from `Spec.Values` +- Serve as foundation for configuration + +**Optional Values:** +- Conditional based on "when" expression +- Two merge strategies: + - Recursive merge using `kotsv1beta2.MergeHelmChartValues()` + - Direct key replacement using `maps.Copy()` + +## Implementation Considerations + +### 1. Error Handling +- Template parsing failures +- Invalid "when" expressions +- Missing or malformed CRs +- Value conversion errors + +### 2. Backward Compatibility +- Must handle releases without HelmChart CRs +- Default to no values if CRs unavailable +- Maintain existing behavior for non-V3 installs + +### 3. Dependencies +The implementation depends on: +- `kotsv1beta2.HelmChart` type definitions +- `kotsv1beta2.MergeHelmChartValues()` function +- Template engine from `api/pkg/template` +- Existing `generateHelmValues` function + +## Testing Requirements + +### 1. Unit Test Coverage +- Template execution with various config values +- Value merging with different strategies +- Conditional optionalValues evaluation +- Error cases and edge conditions + +### 2. Integration Test Scenarios +- Chart installation with complex values +- Multiple charts with different configurations +- Charts without HelmChart CRs +- Malformed or invalid CRs + +## Risk Assessment + +**Low Risk:** +- Reusing existing, tested functions +- Clear separation of concerns +- Graceful fallback behavior + +**Medium Risk:** +- Template engine initialization complexity +- Config value threading through managers +- Index correlation between CRs and archives + +## Recommendations + +1. **Minimize Code Duplication:** Reuse existing `generateHelmValues` function rather than reimplementing +2. **Fail Gracefully:** Continue installation without values if CR processing fails +3. **Comprehensive Logging:** Add detailed logging for debugging value processing +4. **Incremental Testing:** Test each component independently before integration \ No newline at end of file diff --git a/proposals/v3_app_deployment_transition.md b/proposals/v3_app_deployment_transition.md new file mode 100644 index 0000000000..61f43e4836 --- /dev/null +++ b/proposals/v3_app_deployment_transition.md @@ -0,0 +1,627 @@ +# Proposal: Transition Application Deployment to V3 Embedded-Cluster + +**Status:** Proposed +**Epic:** [Installer Experience v2 - Milestone 7](https://app.shortcut.com/replicated/epic/126565) +**Related Stories:** + +| Story | Description | +|-------|-------------| +| sc-128049 | Update KOTS to not deploy the app for v3 EC installs | +| sc-128065 | Make sure the values and optionalValues fields of the HelmChart custom resource are respected | +| sc-128045 | Update app install endpoint to install Helm charts directly without KOTS CLI | +| 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 | 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 | +| sc-128047 | Update the app installation page to show charts being installed with progress | +| sc-128046 | Update app install status endpoint to return list of charts being installed | + +## TL;DR + +Transition application deployment from KOTS to the V3 embedded-cluster binary to deliver Helm charts directly through our installer instead of KOTS. This enables better control, reliability, and user experience while maintaining KOTS functionality for upgrades and management, delivered through iterative milestones. + +## The Problem + +This proposal addresses the larger architectural goal of controlling application lifecycle management outside the cluster through the embedded-cluster binary, while ensuring KOTS continues to function end-to-end during the transition. + +**Long-term vision:** Move entire application deployment and lifecycle management from in-cluster KOTS to the external embedded-cluster binary for better control, reliability, and user experience. + +**Current epic goal:** Enable the V3 embedded-cluster binary to handle initial application deployment with full HelmChart resource support while allowing KOTS to seamlessly take over lifecycle management post-install. + +**Current technical problem:** The V3 API installation manager delegates entirely to KOTS CLI for application deployment, preventing direct control over Helm chart installation and HelmChart custom resource configuration. If we were to add deployment logic to the V3 installer without stopping KOTS from deploying the app too, both components would attempt to deploy the app during initial install, leading to resource conflicts and unclear ownership. + +## Prototype / Design + +The solution transitions application deployment from KOTS to the V3 embedded-cluster binary: + +### Flow Diagram + +V3 Install API + ↓ +Setup Helm Client + ↓ +Install Charts (V3 Binary) + ↓ +Call KOTS CLI (Skip Deploy) + +### Flow Details + +1. **V3 binary takes ownership** of application deployment +2. **Setup Helm client** following infra manager patterns +3. **Install charts directly** from releaseData.HelmChartArchives with full HelmChart custom resource support +4. **Call KOTS CLI** with SkipDeploy: true for metadata management only +5. **Fail fast** on errors without complex rollback logic + +## Implementation Plan + +### Iteration 1: Core Deployment Transition + +#### 1.1 Story sc-128049: Update KOTS to not deploy the app for v3 EC installs + +**Purpose:** Prevent KOTS from attempting to deploy applications during V3 initial installs to avoid resource conflicts. + +**Implementation:** +- Add detection for V3 EC initial installs in KOTS DeployApp function +- Skip deployment and create version record when V3 is handling the deployment +- Use `IS_EMBEDDED_CLUSTER_V3` environment variable for detection +- Pass the `IS_EMBEDDED_CLUSTER_V3` environment variable to the admin console chart in V3 installer. + +```go +// pkg/operator/operator.go - DeployApp method +if util.IsV3EmbeddedClusterInitialInstall(sequence) { + // Skip deployment, create success record for admin console + return true, nil +} +// Continue with normal KOTS deployment logic... + +// pkg/util/util.go - Detection utilities +func IsV3EmbeddedCluster() bool { + return os.Getenv("IS_EMBEDDED_CLUSTER_V3") == "true" +} + +func IsV3EmbeddedClusterInitialInstall(sequence int64) bool { + return IsV3EmbeddedCluster() && sequence == 0 +} +``` + +In `embedded-cluster` repo (`pkg/addons/adminconsole/values.go`): + +```go +// ... +copiedValues["isEmbeddedClusterV3"] = a.IsV3 +// ... +``` + +#### 1.2 Story sc-128045: Update app install endpoint to install Helm charts directly + +**Purpose:** Enable V3 binary to install Helm charts directly without using KOTS CLI. + +**Implementation:** +- Add Helm client to app install manager +- Install charts from releaseData.HelmChartArchives before calling KOTS +- Call KOTS CLI with IS_EMBEDDED_CLUSTER_V3=true environment variable for metadata management only + +```go +// api/internal/managers/app/install/manager.go +type appInstallManager struct { + hcli *helm.Client // New: Helm client for direct chart installation + // existing fields... +} + +// api/internal/managers/app/install/install.go +func (m *appInstallManager) Install(ctx context.Context) error { + // New: Install Helm charts directly + for _, chart := range m.releaseData.HelmChartArchives { + err := m.installHelmChart(ctx, chart) // install with defaults + } + + // Existing: Call KOTS CLI for metadata only + return m.kotsClient.Install(kotsInstallArgs{SkipDeploy: true}) +} +``` + +### Iteration 2: Full HelmChart Resource Implementation + +--- + +#### 2.1 Story sc-128065: Support values and optionalValues fields + +**Purpose:** Process HelmChart CR values and optionalValues fields to configure chart installations. + +**Implementation:** +- Add ExtractInstallableHelmCharts method to app release manager +- Template HelmChart CRs using existing templateHelmChartCRs function +- Find the corresponding chart archive for this HelmChart CR +- Generate Helm values from the templated CR using existing generateHelmValues function which takes care of both values and optionalValues fields +- Return installable charts with archive, processed values, and CR +- Controller orchestrates data flow between release and install managers + +```go +// api/internal/managers/app/release/manager.go - New method +func (m *appReleaseManager) ExtractInstallableHelmCharts(ctx context.Context, configValues types.AppConfigValues) ([]InstallableHelmChart, error) { + // Template Helm chart CRs with config values + templatedCRs, err := m.templateHelmChartCRs(configValues) + + // Iterate over each templated CR and create installable chart with processed values + for _, cr := range templatedCRs { + // Find the corresponding chart archive for this HelmChart CR + chartArchive, err := findChartArchive(m.releaseData.HelmChartArchives, cr) + + // Generate Helm values from the templated CR + values, err := generateHelmValues(cr) + + // Create installable chart with archive, processed values, and CR + installableChart := types.InstallableHelmChart{ + // ... + } + } +} + +// api/controllers/app/install/appinstall.go - Controller orchestration +func (c *AppInstallController) Install(ctx context.Context) error { + charts, _ := c.appReleaseManager.ExtractInstallableHelmCharts(ctx, configValues) + return c.appInstallManager.Install(ctx, charts) +} +``` + +--- + +#### 2.2 Story sc-128062: Support releaseName field + +**Purpose:** Use custom release names from HelmChart CR instead of chart name. + +**Implementation:** +- Use releaseName from HelmChart CR spec when available +- Fall back to chart name as default + +```go +// api/internal/managers/app/install/install.go +func (m *appInstallManager) installHelmChart(ctx context.Context, chart InstallableHelmChart) error { + return m.hcli.Install(ctx, helm.InstallOptions{ReleaseName: chart.CR.GetReleaseName(), ...}) +} +``` + +--- + +#### 2.3 Story sc-128055: Support namespace field + +**Purpose:** Install charts to namespaces specified in HelmChart CR. + +**Implementation:** +- Use namespace from HelmChart CR spec when available +- Create namespace if it doesn't exist +- Fall back to kotsadm namespace as default + +```go +// api/internal/managers/app/install/install.go +func (m *appInstallManager) installHelmChart(ctx context.Context, chart InstallableHelmChart) error { + // Fallback to admin console namespace if namespace is not set + namespace := installableChart.CR.GetNamespace() + if namespace == "" { + namespace = constants.KotsadmNamespace + } + return m.hcli.Install(ctx, helm.InstallOptions{Namespace: namespace, ...}) +} +``` + +--- + +#### 2.4 Story sc-128056: Support exclude field + +**Purpose:** Skip installation of charts marked as excluded in HelmChart CR. + +**Implementation:** +- Evaluate exclude expression using template functions during chart extraction +- Skip excluded charts from installable chart list + +```go +// api/internal/managers/app/release/template.go +func (m *appReleaseManager) ExtractInstallableHelmCharts(...) ([]InstallableHelmChart, error) { + // Iterate over each templated CR and create installable chart with processed values + for _, cr := range templatedCRs { + // Check if the chart should be excluded + if !cr.Spec.Exclude.IsEmpty() { + exclude, err := cr.Spec.Exclude.Boolean() + if exclude { + continue + } + } + } +} +``` + +--- + +#### 2.5 Story sc-128057: Sort charts by weight field + +**Purpose:** Install charts in order specified by weight field in HelmChart CR. + +**Implementation:** +- Sort installable charts by weight field before returning from ExtractInstallableHelmCharts +- Use weight 0 as default for charts without weight specified + +```go +// api/internal/managers/app/release/template.go +func (m *appReleaseManager) ExtractInstallableHelmCharts(...) ([]InstallableHelmChart, error) { + // Build installable charts list... + + // Sort by weight before returning + sort.Slice(installableCharts, func(i, j int) bool { + return installableCharts[i].CR.Spec.Weight < installableCharts[j].CR.Spec.Weight + }) +} +``` + +--- + +#### 2.6 Story sc-128058: Use Helm binary instead of the Go SDK + +**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:** +- 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{...} + + // Pass upgrade flags directly as extra args + if len(chart.CR.Spec.HelmUpgradeFlags) > 0 { + opts.ExtraArgs = append(opts.ExtraArgs, chart.CR.Spec.HelmUpgradeFlags...) + } + + return m.hcli.Install(ctx, opts) +} + +// 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 +} +``` + +### Iteration 3: Complete Registry Integration + +--- + +#### 3.1 Story sc-128364: Rely on KOTS CLI to process the app's airgap bundle and create the image pull secrets + +**Purpose:** Since we're cutting airgap bundle processing and image pull secret creation out of scope for this epic, we need to keep relying on KOTS CLI to achieve this. + +**Implementation:** +- Call the `kots install` CLI command before we install the app's helm charts: https://github.com/replicatedhq/embedded-cluster/blob/445ac7500f9eef2e958596eea59d119df559471f/api/internal/managers/app/install/install.go#L47 +- KOTS will process the airgap bundle and create the image pull secrets in the cluster without deploying the application. + +```go +// api/internal/managers/app/install/install.go +func (m *appInstallManager) install(ctx context.Context, installableCharts []types.InstallableHelmChart, kotsConfigValues kotsv1beta1.ConfigValues) error { + // Move before the chart installation + // ... + if err := kotscli.Install(installOpts); err != nil { + return err + } + + // Continue with chart installation... +} +``` + +--- + +#### 3.2 Story sc-128060: Add missing functionality for the image pull secret template functions + +**Purpose:** Enable HelmChart values to reference image pull secrets using template functions in both online and airgap installations. + +**Implementation:** +- Current implementation of `CalculateRegistrySettings` and the corresponding template functions only supports airgap mode. +- Enhance `CalculateRegistrySettings` to support online mode by using the replicated proxy registry and the license ID as auth, but keep `HasLocalRegistry` as false. + +```go +// api/internal/managers/linux/installation/config.go +func (m *installationManager) CalculateRegistrySettings(ctx context.Context, rc runtimeconfig.RuntimeConfig) (*types.RegistrySettings, error) { + if m.airgapBundle == "" { + + authConfig := fmt.Sprintf(`{"auths":{"%s":{"username": "LICENSE_ID", "password": "%s"}}}`, replicatedProxyDomain, licenseID) + imagePullSecretValue := base64.StdEncoding.EncodeToString([]byte(authConfig)) + + return &types.RegistrySettings{ + HasLocalRegistry: false, + ImagePullSecretName: "-registry", + ImagePullSecretValue: imagePullSecretValue, + }, nil + } + + // Existing airgap mode implementation +} +``` + +### Iteration 4: Enhanced User Experience + +--- + +#### 4.1 Story sc-128046: Update app install status endpoint to return list of charts + +**Purpose:** Provide detailed installation progress information via API. + +**Implementation:** +- Use same schema as infra components (api/types/infra.go pattern) +- Track installation progress in app install manager using Status type +- Return App struct with components array + +```go +// api/types/app.go - New types (matching infra.go schema) +type App struct { + Components []AppComponent `json:"components"` + Logs string `json:"logs"` + Status Status `json:"status"` +} + +type AppComponent struct { + Name string `json:"name"` // Chart name + Status Status `json:"status"` // Uses existing Status type with State/Description/LastUpdated +} + +// api/controllers/app/install/status.go - New endpoint +func (c *AppInstallController) GetAppStatus(ctx context.Context) (*App, error) { + status := c.appInstallManager.GetAppStatus() + // Return App struct with component status using existing Status type +} +``` + +--- + +#### 4.2 Story sc-128047: Update app installation page to show charts with progress + +**Purpose:** Display real-time chart installation progress in the UI. + +**Implementation:** +- Follow same patterns as infra components display (LinuxInstallationPhase/KubernetesInstallationPhase) +- Use existing StatusIndicator component for individual chart status +- Poll every 2 seconds using React Query (same as infra) + +```typescript +// web/src/components/wizard/installation/phases/AppInstallationPhase.tsx +const AppInstallationPhase: React.FC = () => { + const { data: appStatus } = useQuery({ + queryKey: ['app-install-status'], + queryFn: () => fetch(`/api/${target}/install/app/status`).then(res => res.json()), + refetchInterval: 2000 // Same as infra components + }); + + return ( +
+ {appStatus?.components?.map(component => ( + + ))} +
+ ); +}; +``` + +## Key Technical Decisions + +### 1. V3 Binary Ownership +- **Decision:** V3 embedded-cluster binary takes full ownership of application deployment +- **Rationale:** Enables better control, reliability, and iterative improvement outside KOTS + +### 2. KOTS Delegation Model +- **Decision:** KOTS delegates deployment to V3 binary but maintains metadata management +- **Rationale:** Preserves KOTS functionality for upgrades while transitioning deployment control + +### 3. V3 API Isolation +- **Decision:** Only apply changes to V3 API installations +- **Rationale:** Zero risk to existing production installations +- **Benefit:** Can iterate and improve without backward compatibility concerns + +### 4. Environment Variable Toggle +- **Decision:** Use EMBEDDED_CLUSTER_V3 environment variable for detection +- **Rationale:** Clear delegation mechanism, no feature flags required + +## External Contracts + +### API Endpoints (Iteration 4 Changes) +- **GET** `/linux/install/app/status` - Enhanced response structure with AppComponent array +- **GET** `/kubernetes/install/app/status` - Enhanced response structure with AppComponent array +- **POST** `/linux/install/app/install` - Same request structure (no changes) +- **POST** `/kubernetes/install/app/install` - Same request structure (no changes) + +### New Response Types (Iteration 4) +```go +// Follows exact same schema as api/types/infra.go +type App struct { + Components []AppComponent `json:"components"` + Logs string `json:"logs"` + Status Status `json:"status"` +} + +type AppComponent struct { + Name string `json:"name"` + Status Status `json:"status"` +} +``` + +### Environment Variables (Iteration 1) +- **IS_EMBEDDED_CLUSTER_V3** - New environment variable for KOTS/V3 coordination + +### Preserved Contracts +- App install request structure unchanged +- Version record format unchanged +- All existing API endpoints maintain backward compatibility +- KOTS CLI integration preserved for metadata management + +## Testing + +### Iteration 1 Testing +**Unit Tests:** +- `install_test.go` - Helm client mock integration, basic chart installation flow +- V3 detection utility tests, environment variable handling +- KOTS skip deployment logic tests + +**Integration Tests:** +- `appinstall_test.go` (Linux/Kubernetes) - V3 vs KOTS coordination scenarios +- Basic chart installation end-to-end flow + +### Iteration 2 Testing +**Unit Tests:** +- `template_test.go` - HelmChart CR processing, values/optionalValues generation +- Controller orchestration tests between release and install managers +- Namespace, releaseName, exclude, weight, helmUpgradeFlags field handling +- Error scenarios for malformed HelmChart CRs + +**Integration Tests:** +- Complex HelmChart scenarios with all supported fields +- Multi-chart installations with weight ordering +- Namespace creation and chart installation in custom namespaces +- Excluded chart scenarios (skip installation based on conditions) + +### Iteration 3 Testing +**Unit Tests:** +- `airgap_test.go` - Image extraction and pushing logic +- `secrets_test.go` - Image pull secret creation and management +- Template function tests for registry-related functions +- Registry authentication scenarios + +**Integration Tests:** +- Airgap installation end-to-end scenarios +- Image pull secret functionality across different namespaces +- Template function integration with actual chart values + +### Iteration 4 Testing +**Unit Tests:** +- `status_test.go` - Progress tracking accuracy, concurrent access handling +- Chart status transitions and error state management +- API response structure validation + +**Integration Tests:** +- Real-time progress reporting during chart installations +- UI integration testing for progress display components +- Status endpoint performance under load + +**Frontend Tests:** +- Chart progress component testing with various status scenarios +- Real-time updates and error state handling +- User interaction and accessibility testing + +### Compatibility Testing (All Iterations) +- Non-V3 installations continue to work unchanged (KOTS deployment) +- Existing V3 API functionality preserved +- KOTS CLI integration maintained for metadata management + +## Backward Compatibility + +### Backward Compatibility Maintained + +**Installation Types:** +- **Non-V3 installations** continue using KOTS deployment unchanged (no risk to existing production) +- **All V3 installations** will have IS_EMBEDDED_CLUSTER_V3=true and use new direct Helm deployment +- **Clear separation:** V3 vs non-V3 installation types, no mixed modes + +**KOTS Integration Preserved:** +- KOTS CLI integration maintained for metadata management +- Upgrade and lifecycle management continue working +- Version records and deployment history preserved + +## Trade-offs + +**Optimizing for:** Complete transition of application deployment ownership from KOTS to V3 embedded-cluster binary while maintaining end-to-end functionality + +**Trade-offs made:** + +### Architecture Trade-offs +- **Deployment Ownership Split:** V3 binary handles deployment, KOTS handles metadata management + - *Rationale:* Enables gradual transition while preserving admin console functionality + - *Mitigation:* Clear delegation model with IS_EMBEDDED_CLUSTER_V3 environment variable detection + +- **Controller Orchestration Pattern:** Release manager processes HelmChart CRs, install manager handles installation + - *Rationale:* Follows existing API architecture patterns (ExtractAppPreflightSpec model) + - *Mitigation:* Proven pattern reduces integration risk + +### Implementation Trade-offs +- **Sequential Chart Processing:** Charts installed one at a time vs parallel installation + - *Rationale:* Simpler error handling, respects weight-based ordering, easier progress tracking + - *Mitigation:* Can optimize for parallel processing in future iterations if needed + +- **Flag Parsing Approach:** Use pflag library vs custom parsing for helmUpgradeFlags + - *Rationale:* Robust handling of various flag formats, proven library used by helm/kubectl + - *Mitigation:* Coordinate with data team to ensure vendor compatibility + +## Alternative Solutions Considered + +1. **Remove KOTS from V3 Entirely** + - *Rejected:* Need admin console for upgrades and management + - Would require significant changes beyond epic scope + +2. **Gradual Feature Migration** + - *Rejected:* Creates more complexity than clean delegation model + - Would increase API surface area unnecessarily + +## Research + +### Prior Art in Codebase + +**Helm Client Integration:** +- `pkg/helm/client.go` - Existing Helm client implementation patterns +- `api/internal/managers/linux/infra/util.go` - Helm client initialization and error handling patterns +- `pkg/addons/registry/install.go` - Registry integration patterns for Helm + +**HelmChart Processing:** +- `api/pkg/template/engine.go` - Template processing engine (leverage for HelmChart CR templating) +- `api/pkg/template/registry.go` - Registry template functions (extend for image pull secrets) +- `api/internal/managers/app/release/template.go` - Existing `generateHelmValues` and `templateHelmChartCRs` functions + +**Manager Architecture Patterns:** +- `api/internal/managers/app/release/manager.go` - `ExtractAppPreflightSpec` pattern (model for `ExtractInstallableHelmCharts`) +- `api/controllers/app/install/controller.go` - Controller orchestration between managers +- `api/internal/managers/linux/infra/` - Manager structure and dependency injection patterns + +**Airgap and Registry:** +- `cmd/local-artifact-mirror/pull_images.go` - Image pulling and processing patterns +- `pkg/artifacts/registryauth.go` - Registry authentication handling +- `api/internal/managers/linux/installation/config.go` - `CalculateRegistrySettings` function (Line 240) + +**Status and Progress Tracking:** +- `web/src/components/wizard/installation/phases/AppInstallationPhase.tsx` - Existing installation progress UI patterns +- `api/internal/handlers/linux/install.go` - Status endpoint patterns +- `api/types/app.go` - Existing app-related type definitions + +**KOTS Integration:** +- `operator/controllers/installation_controller.go` - Existing operator patterns +- `pkg/configutils/kots.go` - KOTS configuration and utility patterns +- `cmd/installer/cli/install.go` - Environment variable handling patterns + +### External References + +**Helm Integration:** +- [Helm Go SDK Documentation](https://pkg.go.dev/helm.sh/helm/v3) - InstallOptions, client patterns +- [Helm CLI Source](https://github.com/helm/helm/blob/main/pkg/cmd/install.go#L187-L235) - Flag definitions for helmUpgradeFlags parsing +- [spf13/pflag Documentation](https://pkg.go.dev/github.com/spf13/pflag) - Flag parsing library + +**Airgap Image Handling:** +- [KOTS Airgap Implementation](https://github.com/replicatedhq/kots/blob/main/pkg/image/airgap.go) - Image pushing patterns using containers/image/v5/copy +- [containers/image Documentation](https://pkg.go.dev/github.com/containers/image/v5) - Image manipulation library + +**Architecture Patterns:** +- KOTS CLI Architecture - Metadata management delegation patterns +- Kubernetes Operator Patterns - Controller reconciliation and status management diff --git a/tests/dryrun/install_test.go b/tests/dryrun/install_test.go index ce1240e7d4..97c9e591d8 100644 --- a/tests/dryrun/install_test.go +++ b/tests/dryrun/install_test.go @@ -101,6 +101,7 @@ func testDefaultInstallationImpl(t *testing.T) { "embeddedClusterID": in.Spec.ClusterID, "embeddedClusterDataDir": "/var/lib/embedded-cluster", "embeddedClusterK0sDir": "/var/lib/embedded-cluster/k0s", + "isEmbeddedClusterV3": false, }) assertHelmValuePrefixes(t, adminConsoleOpts.Values, map[string]string{ "images.kotsadm": "fake-replicated-proxy.test.net/anonymous", 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/phases/AppInstallationPhase.tsx b/web/src/components/wizard/installation/phases/AppInstallationPhase.tsx index 6f659cc4c2..5ac61c1e82 100644 --- a/web/src/components/wizard/installation/phases/AppInstallationPhase.tsx +++ b/web/src/components/wizard/installation/phases/AppInstallationPhase.tsx @@ -3,9 +3,11 @@ import { useWizard } from "../../../../contexts/WizardModeContext"; import { useSettings } from "../../../../contexts/SettingsContext"; import { useAuth } from "../../../../contexts/AuthContext"; import { useQuery } from "@tanstack/react-query"; -import { XCircle, CheckCircle, Loader2 } from "lucide-react"; import { NextButtonConfig } from "../types"; -import { State, AppInstallStatus } from "../../../../types"; +import { State, AppInstallStatusResponse } from "../../../../types"; +import InstallationProgress from '../shared/InstallationProgress'; +import LogViewer from '../shared/LogViewer'; +import StatusIndicator from '../shared/StatusIndicator'; import ErrorMessage from "../shared/ErrorMessage"; interface AppInstallationPhaseProps { @@ -21,10 +23,11 @@ const AppInstallationPhase: React.FC = ({ onNext, set const [isPolling, setIsPolling] = useState(true); const [installationComplete, setInstallationComplete] = useState(false); const [installationSuccess, setInstallationSuccess] = useState(false); + const [showLogs, setShowLogs] = useState(false); const themeColor = settings.themeColor; // Query to poll app installation status - const { data: appInstallStatus, error: appStatusError } = useQuery({ + const { data: appInstallStatus, error: appStatusError } = useQuery({ queryKey: ["appInstallationStatus"], queryFn: async () => { const response = await fetch(`/api/${target}/install/app/status`, { @@ -37,7 +40,7 @@ const AppInstallationPhase: React.FC = ({ onNext, set const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || "Failed to get app installation status"); } - return response.json() as Promise; + return response.json() as Promise; }, enabled: isPolling, refetchInterval: 2000, @@ -64,6 +67,15 @@ const AppInstallationPhase: React.FC = ({ onNext, set } }, [appInstallStatus, handleInstallationComplete]); + const getProgress = () => { + const components = appInstallStatus?.components || []; + if (components.length === 0) { + return 0; + } + const completedComponents = components.filter(component => component.status?.state === 'Succeeded').length; + return Math.round((completedComponents / components.length) * 100); + } + // Update next button configuration useEffect(() => { setNextButtonConfig({ @@ -72,59 +84,37 @@ const AppInstallationPhase: React.FC = ({ onNext, set }); }, [installationComplete, installationSuccess]); - const renderInstallationStatus = () => { - // Loading state - if (isPolling) { - return ( -
- -

Installing application...

-

- {appInstallStatus?.status?.description || "Please wait while we install your application."} -

-
- ); - } + const renderApplicationPhase = () => ( +
+ - // Success state - if (appInstallStatus?.status?.state === "Succeeded") { - return ( -
-
- -
-

Application installed successfully!

-

Your application is now ready to use.

-
- ); - } +
+ {(appInstallStatus?.components || []).map((component, index) => ( + + ))} +
- // Error state - if (appInstallStatus?.status?.state === "Failed") { - return ( -
-
- -
-

Application installation failed

-

- {appInstallStatus?.status?.description || "An error occurred during installation."} -

-
- ); - } + setShowLogs(!showLogs)} + /> - // Default loading state - return ( -
- -

Preparing installation...

-
- ); - }; + {appStatusError && } + {appInstallStatus?.status?.state === 'Failed' && } +
+ ); return (
@@ -133,9 +123,7 @@ const AppInstallationPhase: React.FC = ({ onNext, set

{text.appInstallationDescription}

- {renderInstallationStatus()} - - {appStatusError && } + {renderApplicationPhase()} ); }; 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}
))} diff --git a/web/src/components/wizard/installation/tests/AppInstallationPhase.test.tsx b/web/src/components/wizard/installation/tests/AppInstallationPhase.test.tsx index 153e31f55e..3eb9808e56 100644 --- a/web/src/components/wizard/installation/tests/AppInstallationPhase.test.tsx +++ b/web/src/components/wizard/installation/tests/AppInstallationPhase.test.tsx @@ -1,5 +1,4 @@ import { describe, it, expect, vi, beforeAll, afterEach, afterAll } from 'vitest'; -import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; @@ -13,9 +12,9 @@ const createServer = (target: 'linux' | 'kubernetes') => setupServer( // Mock app installation status endpoint http.get(`*/api/${target}/install/app/status`, () => { return HttpResponse.json({ - status: { - state: 'Running', - description: 'Installing application components...' + status: { + state: 'Running', + description: 'Installing application components...' } }); }) @@ -67,9 +66,9 @@ describe.each([ server.use( http.get(`*/api/${target}/install/app/status`, () => { return HttpResponse.json({ - status: { - state: 'Running', - description: 'Installing application components...' + status: { + state: 'Running', + description: 'Installing application components...' } }); }) @@ -88,10 +87,10 @@ describe.each([ } ); - // Should show loading state + // Should show installation in progress (log viewer should be present) await waitFor(() => { - expect(screen.getByTestId('app-installation-loading')).toBeInTheDocument(); - expect(screen.getByTestId('app-installation-loading-description')).toBeInTheDocument(); + expect(screen.getByTestId('log-viewer')).toBeInTheDocument(); + expect(screen.getByText('Installing application components...')).toBeInTheDocument(); }); // Next button should be disabled during installation @@ -105,9 +104,9 @@ describe.each([ server.use( http.get(`*/api/${target}/install/app/status`, () => { return HttpResponse.json({ - status: { - state: 'Succeeded', - description: 'Application installed successfully' + status: { + state: 'Succeeded', + description: 'Application installed successfully' } }); }) @@ -126,9 +125,9 @@ describe.each([ } ); - // Should show success state + // Should show success message in progress area await waitFor(() => { - expect(screen.getByTestId('app-installation-success')).toBeInTheDocument(); + expect(screen.getByText('Application installed successfully')).toBeInTheDocument(); }); // Next button should be enabled after successful installation @@ -137,7 +136,7 @@ describe.each([ expect(nextButton).not.toBeDisabled(); }); - // Should call onStateChange with "Succeeded" + // Should call onStateChange with "Succeeded" await waitFor(() => { const calls = mockOnStateChange.mock.calls.map(args => args[0]); expect(calls).toEqual(['Running', 'Succeeded']); @@ -149,9 +148,9 @@ describe.each([ server.use( http.get(`*/api/${target}/install/app/status`, () => { return HttpResponse.json({ - status: { - state: 'Failed', - description: 'Installation failed due to insufficient resources' + status: { + state: 'Failed', + description: 'Installation failed due to insufficient resources' } }); }) @@ -170,10 +169,10 @@ describe.each([ } ); - // Should show error state + // Should show error message await waitFor(() => { - expect(screen.getByTestId('app-installation-error')).toBeInTheDocument(); - expect(screen.getByTestId('app-installation-error-message')).toBeInTheDocument(); + expect(screen.getByTestId('error-message')).toBeInTheDocument(); + expect(screen.getAllByText('Installation failed due to insufficient resources')).toHaveLength(2); }); // Next button should be disabled after failed installation @@ -280,9 +279,9 @@ describe.each([ } ); - // Should show default loading state + // Should show log viewer (component is rendered) await waitFor(() => { - expect(screen.getByTestId('app-installation-loading')).toBeInTheDocument(); + expect(screen.getByTestId('log-viewer')).toBeInTheDocument(); }); // Next button should be disabled @@ -298,9 +297,9 @@ describe.each([ http.get(`*/api/${target}/install/app/status`, () => { callCount++; return HttpResponse.json({ - status: { - state: 'Succeeded', - description: 'Application installed successfully' + status: { + state: 'Succeeded', + description: 'Application installed successfully' } }); }) @@ -319,16 +318,16 @@ describe.each([ } ); - // Wait for success state + // Wait for success message to appear await waitFor(() => { - expect(screen.getByTestId('app-installation-success')).toBeInTheDocument(); + expect(screen.getByText('Application installed successfully')).toBeInTheDocument(); }); const initialCallCount = callCount; - + // Wait a bit more and ensure no additional calls are made await new Promise(resolve => setTimeout(resolve, 3000)); - + // Should not make additional API calls after success expect(callCount).toBeLessThanOrEqual(initialCallCount + 1); // Allow for one potential additional call due to timing }); @@ -339,9 +338,9 @@ describe.each([ http.get(`*/api/${target}/install/app/status`, () => { callCount++; return HttpResponse.json({ - status: { - state: 'Failed', - description: 'Installation failed' + status: { + state: 'Failed', + description: 'Installation failed' } }); }) @@ -360,29 +359,29 @@ describe.each([ } ); - // Wait for failure state + // Wait for error message to appear await waitFor(() => { - expect(screen.getByTestId('app-installation-error')).toBeInTheDocument(); + expect(screen.getByTestId('error-message')).toBeInTheDocument(); }); const initialCallCount = callCount; - + // Wait a bit more and ensure no additional calls are made await new Promise(resolve => setTimeout(resolve, 3000)); - + // Should not make additional API calls after failure expect(callCount).toBeLessThanOrEqual(initialCallCount + 1); // Allow for one potential additional call due to timing }); it('displays custom status description when available', async () => { const customDescription = 'Configuring application settings and finalizing setup...'; - + server.use( http.get(`*/api/${target}/install/app/status`, () => { return HttpResponse.json({ - status: { - state: 'Running', - description: customDescription + status: { + state: 'Running', + description: customDescription } }); }) @@ -401,9 +400,9 @@ describe.each([ } ); - // Should display custom description + // Should display custom description in the progress message await waitFor(() => { - expect(screen.getByTestId('app-installation-loading-description')).toBeInTheDocument(); + expect(screen.getByText(customDescription)).toBeInTheDocument(); }); }); @@ -411,7 +410,7 @@ describe.each([ server.use( http.get(`*/api/${target}/install/app/status`, () => { return HttpResponse.json({ - status: { + status: { state: 'Running' } }); @@ -431,9 +430,265 @@ describe.each([ } ); - // Should display fallback message + // Should display fallback message (Preparing installation...) + await waitFor(() => { + expect(screen.getByText('Preparing installation...')).toBeInTheDocument(); + }); + }); + + it('displays individual component status indicators', async () => { + server.use( + http.get(`*/api/${target}/install/app/status`, () => { + return HttpResponse.json({ + status: { + state: 'Running', + description: 'Installing application components...' + }, + components: [ + { + name: 'Nginx App', + status: { state: 'Running', description: 'Installing chart' } + }, + { + name: 'Redis App', + status: { state: 'Succeeded', description: 'Installation complete' } + } + ] + }); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + target, + authenticated: true + } + } + ); + + // Should display component status indicators + await waitFor(() => { + expect(screen.getByText('Nginx App')).toBeInTheDocument(); + expect(screen.getByText('Redis App')).toBeInTheDocument(); + }); + }); + + it('calculates progress correctly with multiple components', async () => { + server.use( + http.get(`*/api/${target}/install/app/status`, () => { + return HttpResponse.json({ + status: { + state: 'Running', + description: 'Installing application components...' + }, + components: [ + { + name: 'Component 1', + status: { state: 'Succeeded', description: 'Complete' } + }, + { + name: 'Component 2', + status: { state: 'Succeeded', description: 'Complete' } + }, + { + name: 'Component 3', + status: { state: 'Running', description: 'Installing' } + }, + { + name: 'Component 4', + status: { state: 'Pending', description: 'Waiting' } + } + ] + }); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + target, + authenticated: true + } + } + ); + + // Should show progress (2 out of 4 components completed = 50%) + await waitFor(() => { + const progressBar = document.querySelector('[style*="width: 50%"]'); + expect(progressBar).toBeInTheDocument(); + }); + }); + + it('shows 0% progress when no components are provided', async () => { + server.use( + http.get(`*/api/${target}/install/app/status`, () => { + return HttpResponse.json({ + status: { + state: 'Running', + description: 'Preparing installation...' + }, + components: [] + }); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + target, + authenticated: true + } + } + ); + + // Should show 0% progress when no components + await waitFor(() => { + const progressBar = document.querySelector('[style*="width: 0%"]'); + expect(progressBar).toBeInTheDocument(); + }); + }); + + it('shows 100% progress when all components are succeeded', async () => { + server.use( + http.get(`*/api/${target}/install/app/status`, () => { + return HttpResponse.json({ + status: { + state: 'Succeeded', + description: 'Installation complete' + }, + components: [ + { + name: 'Component 1', + status: { state: 'Succeeded', description: 'Complete' } + }, + { + name: 'Component 2', + status: { state: 'Succeeded', description: 'Complete' } + } + ] + }); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + target, + authenticated: true + } + } + ); + + // Should show 100% progress when all components succeeded + await waitFor(() => { + const progressBar = document.querySelector('[style*="width: 100%"]'); + expect(progressBar).toBeInTheDocument(); + }); + }); + + it('handles mixed component states correctly', async () => { + server.use( + http.get(`*/api/${target}/install/app/status`, () => { + return HttpResponse.json({ + status: { + state: 'Running', + description: 'Installing application components...' + }, + components: [ + { + name: 'Database', + status: { state: 'Succeeded', description: 'Installation complete' } + }, + { + name: 'API Server', + status: { state: 'Running', description: 'Installing...' } + }, + { + name: 'Frontend', + status: { state: 'Failed', description: 'Installation failed' } + } + ] + }); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + target, + authenticated: true + } + } + ); + + // Should display all component names and states + await waitFor(() => { + expect(screen.getByText('Database')).toBeInTheDocument(); + expect(screen.getByText('API Server')).toBeInTheDocument(); + expect(screen.getByText('Frontend')).toBeInTheDocument(); + }); + + // Progress should be 33% (1 out of 3 succeeded) + await waitFor(() => { + const progressBar = document.querySelector('[style*="width: 33%"]'); + expect(progressBar).toBeInTheDocument(); + }); + }); + + it('displays logs from app installation', async () => { + const testLogs = '[app] Installing nginx chart\n[kots] Creating namespace\n[kots] Deploying application'; + + server.use( + http.get(`*/api/${target}/install/app/status`, () => { + return HttpResponse.json({ + status: { + state: 'Running', + description: 'Installing application components...' + }, + components: [], + logs: testLogs + }); + }) + ); + + renderWithProviders( + , + { + wrapperProps: { + target, + authenticated: true + } + } + ); + + // Should have log viewer available await waitFor(() => { - expect(screen.getByTestId('app-installation-loading-description')).toBeInTheDocument(); + expect(screen.getByTestId('log-viewer')).toBeInTheDocument(); + expect(screen.getByTestId('log-viewer-toggle')).toBeInTheDocument(); }); }); diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 2294834009..51940a787a 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -8,21 +8,21 @@ export interface InitialState { export type State = 'Pending' | 'Running' | 'Succeeded' | 'Failed'; +export interface Status { + state: State; + description: string; + lastUpdated: string; +} + export interface InfraStatusResponse { components: InfraComponent[]; - status: InfraStatus; + status: Status; logs: string; } export interface InfraComponent { name: string; - status: InfraStatus; -} - -export interface InfraStatus { - state: State; - description: string; - lastUpdated: string; + status: Status; } export type WizardStep = 'welcome' | 'configuration' | 'linux-setup' | 'kubernetes-setup' | 'installation' | 'linux-completion' | 'kubernetes-completion'; @@ -77,37 +77,29 @@ export interface PreflightOutput { fail: PreflightResult[]; } -export interface PreflightStatus { - state: string; - description: string; - lastUpdated: string; -} - export interface HostPreflightResponse { titles: string[]; output?: PreflightOutput; - status?: PreflightStatus; + status?: Status; allowIgnoreHostPreflights?: boolean; } export interface AppPreflightResponse { titles: string[]; output?: PreflightOutput; - status?: PreflightStatus; + status?: Status; allowIgnoreAppPreflights?: boolean; } -export interface AppInstallStatus { - status: { - state: State; - description: string; - lastUpdated: string; - }; +export interface AppInstallStatusResponse { + components: AppComponent[]; + status: Status; logs: string; } -export interface InstallationStatusResponse { - description: string; - lastUpdated: string; - state: State; +export interface AppComponent { + name: string; + status: Status; } + +export type InstallationStatusResponse = Status;